@animalabs/agent-framework 0.19.0 → 0.21.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/README.md +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +153 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1175 -76
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +59 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +106 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +8 -6
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +12 -6
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +9 -4
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +607 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +15 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +65 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +68 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/conversation-router.test.js +33 -19
- package/dist/test/conversation-router.test.js.map +1 -1
- package/dist/test/conversation-routing.test.js +89 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +158 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fields.test.js +82 -0
- package/dist/test/mcpl-reference-fields.test.js.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
- package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
- package/dist/test/tools-observe-parser-conformance.test.js +129 -0
- package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1207 -80
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +115 -7
- package/src/mcpl/conversation-router.ts +12 -8
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/references.ts +11 -3
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +745 -0
- package/src/mcpl/transport.ts +67 -1
- package/src/mcpl/types.ts +71 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
package/dist/src/framework.js
CHANGED
|
@@ -16,7 +16,10 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
|
16
16
|
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
17
17
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
18
18
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
19
|
-
import {
|
|
19
|
+
import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
|
|
20
|
+
import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass } from './mcpl/tool-classes.js';
|
|
21
|
+
import { PushHandler, convertBlock as convertPushBlock } from './mcpl/push-handler.js';
|
|
22
|
+
import { PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent, coalescingSubjectKey, } from './mcpl/push-coalescer.js';
|
|
20
23
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
21
24
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
22
25
|
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
@@ -26,9 +29,9 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
|
26
29
|
import { safeSlice } from './safe-slice.js';
|
|
27
30
|
import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
|
|
28
31
|
import { TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, parseImagePlaceholders, sha256Hex, splitPreservingImageSlots, } from './tool-image-ledger.js';
|
|
29
|
-
import { randomUUID } from 'node:crypto';
|
|
30
|
-
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
31
|
-
import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
|
|
32
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
33
|
+
import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
|
|
34
|
+
import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, scriptTimeLimits, validateCodeExecutionConfig, } from './code-execution/tool-definition.js';
|
|
32
35
|
import { splitProseSegments } from './prose-segments.js';
|
|
33
36
|
import { cumulativeDelta } from './usage-accounting.js';
|
|
34
37
|
import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
|
|
@@ -289,6 +292,10 @@ import { OperatorActionError, OperatorLog, capIds, defaultOperatorLogPath, } fro
|
|
|
289
292
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
290
293
|
/** Snapshot slot mirroring context writes deferred while quiesced (#122). */
|
|
291
294
|
const DEFERRED_WRITES_ID = 'framework/deferred-writes';
|
|
295
|
+
/** RFC-006 coalescing bookkeeping: receipts (retry window) + subject history. */
|
|
296
|
+
const COALESCING_STATE_ID = 'mcpl/coalescing';
|
|
297
|
+
/** Receipts written since the last snapshot flush (bridges the throttle window). */
|
|
298
|
+
const COALESCING_RECENT_ID = 'mcpl/coalescing-recent';
|
|
292
299
|
/** Above this serialized size the deferred-write mirror is skipped (kept in
|
|
293
300
|
* memory only) rather than rewriting a multi-MB snapshot per deferral. */
|
|
294
301
|
const DEFERRED_WRITES_PERSIST_CAP_BYTES = 4 * 1024 * 1024;
|
|
@@ -300,7 +307,8 @@ function withDeferredWriteId(metadata, id) {
|
|
|
300
307
|
return { ...(metadata ?? {}), deferredWriteId: id };
|
|
301
308
|
}
|
|
302
309
|
function isTurnContinuation(reason) {
|
|
303
|
-
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
310
|
+
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
311
|
+
|| reason === 'tool_result_guard_retry';
|
|
304
312
|
}
|
|
305
313
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
306
314
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
@@ -782,7 +790,33 @@ export class AgentFramework {
|
|
|
782
790
|
mcplServerRegistry = null;
|
|
783
791
|
featureSetManager = null;
|
|
784
792
|
hookOrchestrator = null;
|
|
793
|
+
/** MCPL RFC-007: `tools/lifecycle` to observing servers. Created with the
|
|
794
|
+
* MCPL subsystem; inert (no tracking) while no connection holds
|
|
795
|
+
* `toolLifecycle.observe`. */
|
|
796
|
+
toolLifecycleEmitter = null;
|
|
797
|
+
/** MCPL RFC-008 class sources, normalized: operator overrides, then host
|
|
798
|
+
* knowledge (embedding host's table ahead of the built-in one). */
|
|
799
|
+
toolClassOverrides = [];
|
|
800
|
+
hostToolClasses = normalizeClassTable(BUILTIN_TOOL_CLASSES, 'BUILTIN_TOOL_CLASSES');
|
|
785
801
|
pushHandler = null;
|
|
802
|
+
/** RFC-006 coalescing (see mcpl/push-coalescer.ts for the design). */
|
|
803
|
+
pushCoalescer = null;
|
|
804
|
+
/** Both lanes are admitted in arrival order: a create, edit and delete of
|
|
805
|
+
* one subject must not overtake one another while routing awaits. */
|
|
806
|
+
coalescingAdmission = Promise.resolve();
|
|
807
|
+
coalescingSaveTimer = null;
|
|
808
|
+
coalescingSnapshotDirty = null;
|
|
809
|
+
coalescingRecentReceipts = [];
|
|
810
|
+
/**
|
|
811
|
+
* Agents that read the residents' shared un-namespaced message slot: every
|
|
812
|
+
* ordinary resident (their context managers are not isolated) and the
|
|
813
|
+
* subconscious (isolated, but merging that slot read-only). A message in
|
|
814
|
+
* that slot is unread only if NONE of them has compiled past it. Forks and
|
|
815
|
+
* ephemeral agents have isolated slots and are their own only readers.
|
|
816
|
+
*/
|
|
817
|
+
sharedSlotAgents = new Set();
|
|
818
|
+
/** Group commit: one fsync per event-loop turn, shared by every waiter. */
|
|
819
|
+
coalescingCommit = null;
|
|
786
820
|
inferenceRouter = null;
|
|
787
821
|
channelRegistry = null;
|
|
788
822
|
checkpointManager = null;
|
|
@@ -833,10 +867,15 @@ export class AgentFramework {
|
|
|
833
867
|
mcplTools = [];
|
|
834
868
|
/** Namespaced tool name → stateful feature-set attribution from tools/list. */
|
|
835
869
|
mcplToolFeatureSets = new Map();
|
|
870
|
+
/** Namespaced tool name → the server's RFC-008 `_meta["mcpl/class"]`. */
|
|
871
|
+
mcplToolClasses = new Map();
|
|
836
872
|
mcplToolRefreshInFlight = false;
|
|
837
873
|
mcplToolRefreshPending = false;
|
|
838
874
|
/** Maps tool prefix → serverId for dispatch routing. */
|
|
839
875
|
mcplPrefixMap = new Map();
|
|
876
|
+
/** Namespaced tool name → the server whose tools/list produced it. Prefixes
|
|
877
|
+
* can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
|
|
878
|
+
mcplToolServers = new Map();
|
|
840
879
|
/** Maps serverId → McplServerConfig for prefix lookup. */
|
|
841
880
|
mcplServerConfigs = new Map();
|
|
842
881
|
/** Host capabilities advertised during the MCP handshake — stored so servers
|
|
@@ -869,6 +908,8 @@ export class AgentFramework {
|
|
|
869
908
|
eventGate = null;
|
|
870
909
|
// Session-level token usage tracking (always-on)
|
|
871
910
|
usageTracker;
|
|
911
|
+
/** Explicit-send suppression carried into a tool-result-guard retry. */
|
|
912
|
+
guardRetryTurnSilenced = new Map();
|
|
872
913
|
/** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
|
|
873
914
|
timeZone;
|
|
874
915
|
constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone, discordAwarenessOutbox, discordAwarenessEmoji, discordAwarenessDeadlineMs, operatorLog = new OperatorLog(undefined)) {
|
|
@@ -912,6 +953,9 @@ export class AgentFramework {
|
|
|
912
953
|
* Create and start the framework.
|
|
913
954
|
*/
|
|
914
955
|
static async create(config) {
|
|
956
|
+
// Before anything opens or starts: a refused config must leave nothing behind.
|
|
957
|
+
if (config.codeExecution?.enabled)
|
|
958
|
+
validateCodeExecutionConfig(config.codeExecution);
|
|
915
959
|
// Create or use existing store
|
|
916
960
|
let store;
|
|
917
961
|
let ownsStore;
|
|
@@ -939,6 +983,14 @@ export class AgentFramework {
|
|
|
939
983
|
catch {
|
|
940
984
|
// Already registered
|
|
941
985
|
}
|
|
986
|
+
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
|
|
987
|
+
try {
|
|
988
|
+
store.registerState({ id, strategy: 'snapshot' });
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
// Already registered
|
|
992
|
+
}
|
|
993
|
+
}
|
|
942
994
|
try {
|
|
943
995
|
store.registerState({
|
|
944
996
|
id: INFERENCE_LOG_ID,
|
|
@@ -1099,6 +1151,11 @@ export class AgentFramework {
|
|
|
1099
1151
|
// Client-side programmatic tool calling (code_execution). Config is only
|
|
1100
1152
|
// retained when enabled — everything downstream gates on the field.
|
|
1101
1153
|
framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
|
|
1154
|
+
framework.toolClassOverrides = normalizeClassTable(config.toolClassOverrides, 'toolClassOverrides');
|
|
1155
|
+
framework.hostToolClasses = [
|
|
1156
|
+
...normalizeClassTable(config.hostToolClasses, 'hostToolClasses'),
|
|
1157
|
+
...normalizeClassTable(BUILTIN_TOOL_CLASSES, 'BUILTIN_TOOL_CLASSES'),
|
|
1158
|
+
];
|
|
1102
1159
|
if (config.toolResultInlineMaxChars !== undefined) {
|
|
1103
1160
|
const cap = config.toolResultInlineMaxChars;
|
|
1104
1161
|
if (!Number.isFinite(cap) || cap < 1000) {
|
|
@@ -1264,6 +1321,8 @@ export class AgentFramework {
|
|
|
1264
1321
|
* Stop the event loop.
|
|
1265
1322
|
*/
|
|
1266
1323
|
async stop() {
|
|
1324
|
+
this.pushCoalescer?.suspend();
|
|
1325
|
+
this.flushCoalescingSnapshot();
|
|
1267
1326
|
this.running = false;
|
|
1268
1327
|
// Flushed-but-unsynced deferred writes: sync and ack now, while the
|
|
1269
1328
|
// store is still open, rather than leaving them to a reboot replay.
|
|
@@ -1331,6 +1390,16 @@ export class AgentFramework {
|
|
|
1331
1390
|
shutdownPromises.push(this.mcplServerRegistry.closeAll());
|
|
1332
1391
|
}
|
|
1333
1392
|
await Promise.all(shutdownPromises);
|
|
1393
|
+
// Streams may queue storage repairs while being cancelled above. Retry
|
|
1394
|
+
// after their teardown, while the store is still open, before final sync.
|
|
1395
|
+
for (const agent of this.agents.values()) {
|
|
1396
|
+
try {
|
|
1397
|
+
agent.toolResultGuard.flushUnrecorded();
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
console.error(`[tool-result-guard] agent=${agent.name} could not finish queued storage work at stop:`, error);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1334
1403
|
// Final sync before closing
|
|
1335
1404
|
try {
|
|
1336
1405
|
this.store.sync();
|
|
@@ -1592,6 +1661,12 @@ export class AgentFramework {
|
|
|
1592
1661
|
}
|
|
1593
1662
|
async runQueuedMaintenance() {
|
|
1594
1663
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
1664
|
+
try {
|
|
1665
|
+
agent.toolResultGuard.flushUnrecorded();
|
|
1666
|
+
}
|
|
1667
|
+
catch (error) {
|
|
1668
|
+
console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
|
|
1669
|
+
}
|
|
1595
1670
|
const cm = agent.getContextManager();
|
|
1596
1671
|
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
1597
1672
|
cm.setToolDefinitions(tools);
|
|
@@ -1884,6 +1959,11 @@ export class AgentFramework {
|
|
|
1884
1959
|
ext.keys.forEach((k) => taken.add(k));
|
|
1885
1960
|
result.set('_framework', ext);
|
|
1886
1961
|
}
|
|
1962
|
+
{
|
|
1963
|
+
const ext = this.toolResultGuardSettingsExtension();
|
|
1964
|
+
ext.keys.forEach((key) => taken.add(key));
|
|
1965
|
+
result.set('_toolResultGuard', ext);
|
|
1966
|
+
}
|
|
1887
1967
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1888
1968
|
const ext = module.getAgentSettingsExtension?.();
|
|
1889
1969
|
if (!ext)
|
|
@@ -1948,6 +2028,56 @@ export class AgentFramework {
|
|
|
1948
2028
|
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
1949
2029
|
};
|
|
1950
2030
|
}
|
|
2031
|
+
toolResultGuardSettingsExtension() {
|
|
2032
|
+
const get = (name) => {
|
|
2033
|
+
const guard = this.agents.get(name)?.toolResultGuard;
|
|
2034
|
+
return {
|
|
2035
|
+
tool_result_guard: guard?.enabled ?? false,
|
|
2036
|
+
tool_result_guard_source: guard?.settingOverride !== undefined ? 'runtime_override' : 'recipe_default',
|
|
2037
|
+
};
|
|
2038
|
+
};
|
|
2039
|
+
const set = (name, enabled) => {
|
|
2040
|
+
const agent = this.agents.get(name);
|
|
2041
|
+
if (!agent)
|
|
2042
|
+
throw new Error(`Unknown agent: ${name}`);
|
|
2043
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
2044
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
2045
|
+
const values = { ...(state.toolResultGuards ?? {}) };
|
|
2046
|
+
if (enabled === undefined)
|
|
2047
|
+
delete values[name];
|
|
2048
|
+
else
|
|
2049
|
+
values[name] = enabled;
|
|
2050
|
+
state.toolResultGuards = values;
|
|
2051
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
2052
|
+
agent.toolResultGuard.setOverride(enabled);
|
|
2053
|
+
return get(name);
|
|
2054
|
+
};
|
|
2055
|
+
return {
|
|
2056
|
+
properties: {
|
|
2057
|
+
tool_result_guard: {
|
|
2058
|
+
type: 'boolean',
|
|
2059
|
+
description: 'Enable the tool result guard. It can withhold a batch of tool output and retry inference once. ' +
|
|
2060
|
+
'Tools are not re-executed. Persists across turns and restarts until explicitly changed. ' +
|
|
2061
|
+
'Disabling affects future results only; previously withheld results stay withheld.',
|
|
2062
|
+
},
|
|
2063
|
+
},
|
|
2064
|
+
keys: ['tool_result_guard'],
|
|
2065
|
+
get,
|
|
2066
|
+
update: (name, patch) => {
|
|
2067
|
+
if (typeof patch.tool_result_guard !== 'boolean')
|
|
2068
|
+
throw new Error('tool_result_guard must be a boolean');
|
|
2069
|
+
return set(name, patch.tool_result_guard);
|
|
2070
|
+
},
|
|
2071
|
+
reset: (name) => set(name, undefined),
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
restoreToolResultGuardSetting(agent) {
|
|
2075
|
+
const enabled = this.store.getStateJson(FRAMEWORK_STATE_ID)?.toolResultGuards?.[agent.name];
|
|
2076
|
+
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
2077
|
+
throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
|
|
2078
|
+
}
|
|
2079
|
+
agent.toolResultGuard.setOverride(enabled);
|
|
2080
|
+
}
|
|
1951
2081
|
buildThinkTool(policy) {
|
|
1952
2082
|
return {
|
|
1953
2083
|
name: 'think',
|
|
@@ -2024,6 +2154,19 @@ export class AgentFramework {
|
|
|
2024
2154
|
return tool;
|
|
2025
2155
|
});
|
|
2026
2156
|
}
|
|
2157
|
+
/**
|
|
2158
|
+
* The tools one agent is shown at inference: its surface (the subconscious
|
|
2159
|
+
* has its own), less what its permissions deny, plus — for explicit-mode
|
|
2160
|
+
* agents — the on-demand routing reference (teach-by-bounce: the grammar
|
|
2161
|
+
* is never injected, only served when asked). Inference and the RFC-008
|
|
2162
|
+
* listing both use this, so the listing cannot drift from the model's view.
|
|
2163
|
+
*/
|
|
2164
|
+
agentToolSurface(agent, snapshot) {
|
|
2165
|
+
const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name));
|
|
2166
|
+
if (agent.proseRouting === 'explicit')
|
|
2167
|
+
tools.push(PROSE_HELP_TOOL);
|
|
2168
|
+
return tools;
|
|
2169
|
+
}
|
|
2027
2170
|
getAgentRuntimeSettings(agentName) {
|
|
2028
2171
|
const agent = this.agents.get(agentName);
|
|
2029
2172
|
if (!agent)
|
|
@@ -3198,6 +3341,8 @@ export class AgentFramework {
|
|
|
3198
3341
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3199
3342
|
});
|
|
3200
3343
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
3344
|
+
this.restoreToolResultGuardSetting(agent);
|
|
3345
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
3201
3346
|
this.ephemeralCandidates.set(agent, contextManager);
|
|
3202
3347
|
const cleanup = () => {
|
|
3203
3348
|
// Don't close the store — it's shared. Just release the CM.
|
|
@@ -5216,6 +5361,9 @@ export class AgentFramework {
|
|
|
5216
5361
|
viewFilter: (message) => !message.metadata?.tuneOut,
|
|
5217
5362
|
});
|
|
5218
5363
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
5364
|
+
this.restoreToolResultGuardSetting(agent);
|
|
5365
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
5366
|
+
this.sharedSlotAgents.add(agent);
|
|
5219
5367
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
5220
5368
|
if (restoredSettings) {
|
|
5221
5369
|
agent.restoreRuntimeSettings(this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
|
|
@@ -5283,6 +5431,9 @@ export class AgentFramework {
|
|
|
5283
5431
|
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
5284
5432
|
};
|
|
5285
5433
|
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
5434
|
+
this.restoreToolResultGuardSetting(agent);
|
|
5435
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
5436
|
+
this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
|
|
5286
5437
|
this.agents.set(name, agent);
|
|
5287
5438
|
this.agentConfigs.set(name, agentConfig);
|
|
5288
5439
|
this.subconsciousAgentName = name;
|
|
@@ -5348,6 +5499,9 @@ export class AgentFramework {
|
|
|
5348
5499
|
scriptWaiter(event.result);
|
|
5349
5500
|
return;
|
|
5350
5501
|
}
|
|
5502
|
+
// RFC-007: the call's terminal (completed / failed) — the result
|
|
5503
|
+
// itself is never sent, only whether it was an error.
|
|
5504
|
+
this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
|
|
5351
5505
|
const agent = this.agents.get(event.agentName);
|
|
5352
5506
|
if (agent) {
|
|
5353
5507
|
this.touchEphemeralRun(event.agentName, true);
|
|
@@ -5390,7 +5544,8 @@ export class AgentFramework {
|
|
|
5390
5544
|
// byte-matched (the window stores what the membrane sends;
|
|
5391
5545
|
// divergence breaks the compile prefix).
|
|
5392
5546
|
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
|
|
5393
|
-
|
|
5547
|
+
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
|
|
5548
|
+
agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
|
|
5394
5549
|
// Flush any messages that were deferred while this turn was in
|
|
5395
5550
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
5396
5551
|
// framework-level (module messages, push events, subagent return
|
|
@@ -5562,6 +5717,9 @@ export class AgentFramework {
|
|
|
5562
5717
|
}
|
|
5563
5718
|
if (shouldEndTurn) {
|
|
5564
5719
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
5720
|
+
// The batch is never submitted, so nothing can refuse it: admit
|
|
5721
|
+
// it now instead of leaving it pending across the idle gap.
|
|
5722
|
+
agent.toolResultGuard.settleTurnEnded();
|
|
5565
5723
|
if (currentState.stream) {
|
|
5566
5724
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
|
|
5567
5725
|
currentState.stream.cancel();
|
|
@@ -5612,6 +5770,8 @@ export class AgentFramework {
|
|
|
5612
5770
|
reason: 'context_budget_restart',
|
|
5613
5771
|
source: 'framework',
|
|
5614
5772
|
timestamp: Date.now(),
|
|
5773
|
+
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
5774
|
+
ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
|
|
5615
5775
|
});
|
|
5616
5776
|
}
|
|
5617
5777
|
else if (currentState.stream) {
|
|
@@ -5619,8 +5779,7 @@ export class AgentFramework {
|
|
|
5619
5779
|
// Mid-turn messages collected above ride along as injected user
|
|
5620
5780
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
5621
5781
|
// envelope so the next round of THIS turn hears them.
|
|
5622
|
-
|
|
5623
|
-
currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
5782
|
+
currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
5624
5783
|
agent.setStreaming(currentState.stream);
|
|
5625
5784
|
this.emitTrace({
|
|
5626
5785
|
type: 'inference:stream_resumed',
|
|
@@ -5649,6 +5808,16 @@ export class AgentFramework {
|
|
|
5649
5808
|
else if (event.type === 'mcpl:push-event') {
|
|
5650
5809
|
this.handleMcplPushEvent(event);
|
|
5651
5810
|
}
|
|
5811
|
+
await this.dispatchProcessEventToModules(event, startTime);
|
|
5812
|
+
}
|
|
5813
|
+
/**
|
|
5814
|
+
* The module half of handleProcessEvent: every module's onProcess, their
|
|
5815
|
+
* responses, the tool-call dispatch, the completion trace and the process
|
|
5816
|
+
* log. Shared with RFC-006 coalesced deliveries, which bypass the queue
|
|
5817
|
+
* (the coalescer needs the placement synchronously) but must still reach
|
|
5818
|
+
* modules exactly like the ordinary deliveries do.
|
|
5819
|
+
*/
|
|
5820
|
+
async dispatchProcessEventToModules(event, startTime = Date.now()) {
|
|
5652
5821
|
// Dispatch to all modules, tracking responses with module names
|
|
5653
5822
|
const responses = [];
|
|
5654
5823
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
@@ -5743,6 +5912,8 @@ export class AgentFramework {
|
|
|
5743
5912
|
async handleMcplChannelIncoming(event) {
|
|
5744
5913
|
const metadata = {
|
|
5745
5914
|
...event.metadata,
|
|
5915
|
+
...(event.eventId ? { eventId: event.eventId } : {}),
|
|
5916
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5746
5917
|
channelId: event.channelId,
|
|
5747
5918
|
messageId: event.messageId,
|
|
5748
5919
|
author: event.author,
|
|
@@ -5756,9 +5927,39 @@ export class AgentFramework {
|
|
|
5756
5927
|
// Per-channel conversation routing: messages go to the channel's fork
|
|
5757
5928
|
// agent (spawned from the template on first qualifying message), never
|
|
5758
5929
|
// to the primary conversation.
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5930
|
+
// RFC-006 §3.2: a replacement or deletion notice follows the delivery it
|
|
5931
|
+
// corrects — into the agent that holds (or read) the prior occurrence,
|
|
5932
|
+
// never a context fresh routing would pick. Gone agent → delivered nowhere.
|
|
5933
|
+
if (event.deliverTo) {
|
|
5934
|
+
const target = this.agents.get(event.deliverTo);
|
|
5935
|
+
if (!target)
|
|
5936
|
+
return undefined;
|
|
5937
|
+
if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
|
|
5938
|
+
metadata.triggered = event.triggerInference ?? false;
|
|
5939
|
+
const id = target.getContextManager().addMessage('user', event.content, metadata);
|
|
5940
|
+
// A correction may target an older engagement. Refresh only the
|
|
5941
|
+
// binding whose agent actually received the message.
|
|
5942
|
+
if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
|
|
5943
|
+
this.conversationRouter.touch(event.channelId);
|
|
5944
|
+
}
|
|
5945
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
5946
|
+
if (event.triggerInference) {
|
|
5947
|
+
this.pendingRequests.push({
|
|
5948
|
+
agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
|
|
5949
|
+
channelId: event.channelId,
|
|
5950
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
5951
|
+
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
5952
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5953
|
+
});
|
|
5954
|
+
}
|
|
5955
|
+
return { agent: target.name, messageId: id };
|
|
5956
|
+
}
|
|
5957
|
+
if (this.conversationRouter)
|
|
5958
|
+
return undefined; // the trunk never holds channel traffic
|
|
5959
|
+
event = { ...event, targetAgents: [target.name] };
|
|
5960
|
+
}
|
|
5961
|
+
else if (this.conversationRouter) {
|
|
5962
|
+
return this.routeConversationIncoming(event, metadata);
|
|
5762
5963
|
}
|
|
5763
5964
|
// Addressed-while-closed invitation — parity with the push-event path
|
|
5764
5965
|
// (channels.publish surfaces like portal-mcpl deliver mentions here).
|
|
@@ -5796,7 +5997,8 @@ export class AgentFramework {
|
|
|
5796
5997
|
if (divert) {
|
|
5797
5998
|
metadata.tuneOut = { epochId: divert.epochId };
|
|
5798
5999
|
}
|
|
5799
|
-
const
|
|
6000
|
+
const placement = { agent: '' };
|
|
6001
|
+
const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
5800
6002
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
5801
6003
|
if (event.triggerInference && !divert) {
|
|
5802
6004
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
@@ -5822,9 +6024,11 @@ export class AgentFramework {
|
|
|
5822
6024
|
// Addressed messages outrank ambient chatter when a batched wake
|
|
5823
6025
|
// picks the turn's frozen speech locus.
|
|
5824
6026
|
addressed,
|
|
6027
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5825
6028
|
});
|
|
5826
6029
|
}
|
|
5827
6030
|
}
|
|
6031
|
+
return placement.agent ? placement : undefined;
|
|
5828
6032
|
}
|
|
5829
6033
|
/**
|
|
5830
6034
|
* Route an incoming channel message through the ConversationRouter:
|
|
@@ -5893,6 +6097,7 @@ export class AgentFramework {
|
|
|
5893
6097
|
const trigger = decision.trigger && event.triggerInference !== false;
|
|
5894
6098
|
messageMetadata.triggered = trigger;
|
|
5895
6099
|
const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
|
|
6100
|
+
router.touch(event.channelId);
|
|
5896
6101
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
5897
6102
|
if (trigger) {
|
|
5898
6103
|
this.pendingRequests.push({
|
|
@@ -5905,8 +6110,10 @@ export class AgentFramework {
|
|
|
5905
6110
|
channelId: event.channelId,
|
|
5906
6111
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
5907
6112
|
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
6113
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5908
6114
|
});
|
|
5909
6115
|
}
|
|
6116
|
+
return { agent: agent.name, messageId: id };
|
|
5910
6117
|
}
|
|
5911
6118
|
/**
|
|
5912
6119
|
* Spawn a persistent per-channel conversation agent ("fork") from the
|
|
@@ -5955,6 +6162,8 @@ export class AgentFramework {
|
|
|
5955
6162
|
}
|
|
5956
6163
|
const config = { ...templateConfig, name, strategy: undefined };
|
|
5957
6164
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
6165
|
+
this.restoreToolResultGuardSetting(agent);
|
|
6166
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
5958
6167
|
this.agents.set(name, agent);
|
|
5959
6168
|
this.agentConfigs.set(name, config);
|
|
5960
6169
|
this.conversationAgentHomes.set(name, channelId);
|
|
@@ -6112,6 +6321,19 @@ export class AgentFramework {
|
|
|
6112
6321
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
6113
6322
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
6114
6323
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
6324
|
+
// The label is for reading; the prefix must be ONE token that resolves back
|
|
6325
|
+
// here, on this server (a DM label "DM: alice" quoted verbatim parses as
|
|
6326
|
+
// target "#DM:"). A registry that can name targets is authoritative: no safe
|
|
6327
|
+
// token means no prefix option. A stand-in without proseTargetFor gets a
|
|
6328
|
+
// whitespace-free guess, never a label with spaces.
|
|
6329
|
+
const namer = this.channelRegistry;
|
|
6330
|
+
const target = typeof namer.proseTargetFor === 'function'
|
|
6331
|
+
? namer.proseTargetFor(opts.channelId, opts.serverId)
|
|
6332
|
+
: [label ? `#${label}` : undefined, opts.channelId].find((t) => !!t && !/\s/.test(t));
|
|
6333
|
+
const replyOption = target
|
|
6334
|
+
? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
|
|
6335
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
|
|
6336
|
+
: `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
|
|
6115
6337
|
const missedCount = opts.missedMessages ?? 0;
|
|
6116
6338
|
const missedNote = missedCount > 0
|
|
6117
6339
|
? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
|
|
@@ -6125,8 +6347,7 @@ export class AgentFramework {
|
|
|
6125
6347
|
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
6126
6348
|
missedNote +
|
|
6127
6349
|
`Your options:\n` +
|
|
6128
|
-
|
|
6129
|
-
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
|
|
6350
|
+
replyOption +
|
|
6130
6351
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
6131
6352
|
(maxBackscroll > 0
|
|
6132
6353
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -6145,6 +6366,484 @@ export class AgentFramework {
|
|
|
6145
6366
|
},
|
|
6146
6367
|
};
|
|
6147
6368
|
}
|
|
6369
|
+
// ==========================================================================
|
|
6370
|
+
// RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
|
|
6371
|
+
// ==========================================================================
|
|
6372
|
+
/**
|
|
6373
|
+
* RFC-006 §3.2 binding identity: the configured server id bound to its
|
|
6374
|
+
* endpoint/command. Survives reconnects to the same peer; changes when the
|
|
6375
|
+
* id is reassigned to another endpoint (restartMcplServer with a new config).
|
|
6376
|
+
*/
|
|
6377
|
+
coalescingBinding(serverId) {
|
|
6378
|
+
const config = this.mcplServerConfigs.get(serverId);
|
|
6379
|
+
return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
|
|
6380
|
+
}
|
|
6381
|
+
initializePushCoalescer() {
|
|
6382
|
+
const coalescer = new PushCoalescer({
|
|
6383
|
+
isUnread: (p) => this.isUnreadPlacement(p),
|
|
6384
|
+
remove: (p) => this.removePlacement(p),
|
|
6385
|
+
deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
|
|
6386
|
+
wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
|
|
6387
|
+
cancelWake: (subject) => this.cancelCoalescedWakes(subject),
|
|
6388
|
+
authorized: (occ) => this.coalescedAuthorized(occ),
|
|
6389
|
+
audience: (occ) => this.coalescedAudience(occ),
|
|
6390
|
+
render: (occ, params) => {
|
|
6391
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
6392
|
+
if (!server?.isConnected)
|
|
6393
|
+
throw new Error('server disconnected');
|
|
6394
|
+
return server.sendPushRender(params);
|
|
6395
|
+
},
|
|
6396
|
+
audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
|
|
6397
|
+
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
|
|
6398
|
+
recordReceipt: (record) => {
|
|
6399
|
+
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
6400
|
+
// must not lose its receipt, the subject's birth, or (deferred) the
|
|
6401
|
+
// accepted work itself to a crash. A failure here fails the acceptance.
|
|
6402
|
+
this.coalescingRecentReceipts.push(record);
|
|
6403
|
+
this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
|
|
6404
|
+
},
|
|
6405
|
+
wasPublished: (subject, eventId) => {
|
|
6406
|
+
// Boot-time only: the occurrence's durable delivery identity
|
|
6407
|
+
// (subject + eventId in message metadata) in any agent's context.
|
|
6408
|
+
for (const agent of this.agents.values()) {
|
|
6409
|
+
try {
|
|
6410
|
+
if (agent.getContextManager().getAllMessages().some((m) => m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId))
|
|
6411
|
+
return true;
|
|
6412
|
+
}
|
|
6413
|
+
catch { /* a context that cannot be read cannot prove publication */ }
|
|
6414
|
+
}
|
|
6415
|
+
return false;
|
|
6416
|
+
},
|
|
6417
|
+
commit: () => this.commitCoalescingDurable(),
|
|
6418
|
+
saveNow: (snapshot) => {
|
|
6419
|
+
if (this.coalescingSaveTimer) {
|
|
6420
|
+
clearTimeout(this.coalescingSaveTimer);
|
|
6421
|
+
this.coalescingSaveTimer = null;
|
|
6422
|
+
}
|
|
6423
|
+
this.coalescingSnapshotDirty = null;
|
|
6424
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot);
|
|
6425
|
+
this.coalescingRecentReceipts = [];
|
|
6426
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
6427
|
+
},
|
|
6428
|
+
});
|
|
6429
|
+
try {
|
|
6430
|
+
coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID));
|
|
6431
|
+
const recent = this.store.getStateJson(COALESCING_RECENT_ID);
|
|
6432
|
+
if (Array.isArray(recent)) {
|
|
6433
|
+
coalescer.restoreReceipts(recent);
|
|
6434
|
+
// Until the next snapshot flush checkpoints the recovered state, these
|
|
6435
|
+
// records are its only durable form: keep them in the writable buffer
|
|
6436
|
+
// so the next acceptance appends to them instead of replacing them.
|
|
6437
|
+
this.coalescingRecentReceipts = recent;
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
catch (err) {
|
|
6441
|
+
console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
|
|
6442
|
+
}
|
|
6443
|
+
this.pushCoalescer = coalescer;
|
|
6444
|
+
// Batches restored from a snapshot still owe the agent a wake (§14 v41).
|
|
6445
|
+
for (const occ of coalescer.pendingBatchOccurrences())
|
|
6446
|
+
void this.wakeForCoalescedBatch(occ);
|
|
6447
|
+
}
|
|
6448
|
+
/**
|
|
6449
|
+
* Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
|
|
6450
|
+
* written so far is fsynced before the caller proceeds. Group-committed —
|
|
6451
|
+
* all acceptances that arrive in one event-loop turn share one
|
|
6452
|
+
* `store.sync()` — so throughput is bounded by fsync rate per batch, not
|
|
6453
|
+
* per message.
|
|
6454
|
+
*/
|
|
6455
|
+
commitCoalescingDurable() {
|
|
6456
|
+
if (!this.coalescingCommit) {
|
|
6457
|
+
this.coalescingCommit = new Promise((resolve, reject) => {
|
|
6458
|
+
setImmediate(() => {
|
|
6459
|
+
this.coalescingCommit = null;
|
|
6460
|
+
try {
|
|
6461
|
+
this.store.sync();
|
|
6462
|
+
resolve();
|
|
6463
|
+
}
|
|
6464
|
+
catch (err) {
|
|
6465
|
+
reject(err);
|
|
6466
|
+
}
|
|
6467
|
+
});
|
|
6468
|
+
});
|
|
6469
|
+
}
|
|
6470
|
+
return this.coalescingCommit;
|
|
6471
|
+
}
|
|
6472
|
+
scheduleCoalescingSnapshot(snapshot) {
|
|
6473
|
+
this.coalescingSnapshotDirty = snapshot;
|
|
6474
|
+
if (this.coalescingSaveTimer)
|
|
6475
|
+
return;
|
|
6476
|
+
const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
|
|
6477
|
+
timer.unref?.();
|
|
6478
|
+
this.coalescingSaveTimer = timer;
|
|
6479
|
+
}
|
|
6480
|
+
flushCoalescingSnapshot() {
|
|
6481
|
+
if (this.coalescingSaveTimer) {
|
|
6482
|
+
clearTimeout(this.coalescingSaveTimer);
|
|
6483
|
+
this.coalescingSaveTimer = null;
|
|
6484
|
+
}
|
|
6485
|
+
const snapshot = this.coalescingSnapshotDirty;
|
|
6486
|
+
this.coalescingSnapshotDirty = null;
|
|
6487
|
+
if (!snapshot)
|
|
6488
|
+
return;
|
|
6489
|
+
try {
|
|
6490
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot());
|
|
6491
|
+
this.coalescingRecentReceipts = [];
|
|
6492
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
6493
|
+
}
|
|
6494
|
+
catch (err) {
|
|
6495
|
+
console.error('[coalescing] could not persist coalescing state:', err);
|
|
6496
|
+
}
|
|
6497
|
+
}
|
|
6498
|
+
/** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
|
|
6499
|
+
admitCoalesced(work) {
|
|
6500
|
+
const run = this.coalescingAdmission.then(work, work);
|
|
6501
|
+
this.coalescingAdmission = run.then(() => undefined, () => undefined);
|
|
6502
|
+
return run;
|
|
6503
|
+
}
|
|
6504
|
+
handleCoalescedIncoming(serverId, message, event) {
|
|
6505
|
+
return this.admitCoalesced(async () => {
|
|
6506
|
+
if (!this.pushCoalescer)
|
|
6507
|
+
throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
6508
|
+
validateCoalesceMember(message.coalesce, 'channel');
|
|
6509
|
+
if (typeof message.eventId !== 'string' || !message.eventId) {
|
|
6510
|
+
throw new CoalesceError('eventId', 'eventId is required with coalesce');
|
|
6511
|
+
}
|
|
6512
|
+
if (typeof message.timestamp !== 'string')
|
|
6513
|
+
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
6514
|
+
validateCoalescedContent(message.content);
|
|
6515
|
+
const c = message.coalesce;
|
|
6516
|
+
const result = await this.pushCoalescer.accept({
|
|
6517
|
+
serverId,
|
|
6518
|
+
binding: this.coalescingBinding(serverId),
|
|
6519
|
+
scope: { kind: 'channel', id: message.channelId },
|
|
6520
|
+
key: c.key,
|
|
6521
|
+
eventId: message.eventId,
|
|
6522
|
+
timestamp: message.timestamp,
|
|
6523
|
+
retract: !!c.retract,
|
|
6524
|
+
deferred: false,
|
|
6525
|
+
initial: !!c.initial,
|
|
6526
|
+
tags: event.tags,
|
|
6527
|
+
content: message.content,
|
|
6528
|
+
identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
|
|
6529
|
+
event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
|
|
6530
|
+
});
|
|
6531
|
+
return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
|
|
6532
|
+
});
|
|
6533
|
+
}
|
|
6534
|
+
handleCoalescedPush(serverId, params, event) {
|
|
6535
|
+
return this.admitCoalesced(async () => {
|
|
6536
|
+
if (!this.pushCoalescer)
|
|
6537
|
+
throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
6538
|
+
validateCoalesceMember(params.coalesce, 'push');
|
|
6539
|
+
if (typeof params.eventId !== 'string' || !params.eventId)
|
|
6540
|
+
throw new CoalesceError('eventId', 'eventId is required');
|
|
6541
|
+
if (typeof params.timestamp !== 'string')
|
|
6542
|
+
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
6543
|
+
validateCoalescedContent(params.payload?.content);
|
|
6544
|
+
const c = params.coalesce;
|
|
6545
|
+
if (c.channelId)
|
|
6546
|
+
this.assertChannelScopedPush(serverId, c.channelId);
|
|
6547
|
+
const origin = params.origin ?? {};
|
|
6548
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
6549
|
+
return this.pushCoalescer.accept({
|
|
6550
|
+
serverId,
|
|
6551
|
+
binding: this.coalescingBinding(serverId),
|
|
6552
|
+
featureSet: params.featureSet,
|
|
6553
|
+
scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
|
|
6554
|
+
key: c.key,
|
|
6555
|
+
eventId: params.eventId,
|
|
6556
|
+
timestamp: params.timestamp,
|
|
6557
|
+
retract: !!c.retract,
|
|
6558
|
+
deferred: !!c.deferred,
|
|
6559
|
+
initial: !!c.initial,
|
|
6560
|
+
data: c.data,
|
|
6561
|
+
tags: event.tags,
|
|
6562
|
+
content: params.payload.content,
|
|
6563
|
+
...(c.channelId ? {
|
|
6564
|
+
identity: {
|
|
6565
|
+
...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
|
|
6566
|
+
...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
|
|
6567
|
+
...(str(origin.authorId) ? { author: { id: str(origin.authorId), name: str(origin.authorName) ?? str(origin.authorId) } } : {}),
|
|
6568
|
+
},
|
|
6569
|
+
} : {}),
|
|
6570
|
+
event: { lane: 'push', event },
|
|
6571
|
+
});
|
|
6572
|
+
});
|
|
6573
|
+
}
|
|
6574
|
+
/** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
|
|
6575
|
+
assertChannelScopedPush(serverId, channelId) {
|
|
6576
|
+
const server = this.mcplServerRegistry?.getServer(serverId);
|
|
6577
|
+
if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
|
|
6578
|
+
throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
|
|
6579
|
+
}
|
|
6580
|
+
if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
|
|
6581
|
+
throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
coalescedAuthorized(occ) {
|
|
6585
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
6586
|
+
if (!server?.isConnected || !server.policyEstablished)
|
|
6587
|
+
return false;
|
|
6588
|
+
// §3.2: work admitted under another binding (the id since reassigned to a
|
|
6589
|
+
// different endpoint) never acquires the replacement peer's authority —
|
|
6590
|
+
// and its private notice data is never sent to that peer.
|
|
6591
|
+
if (occ.binding !== this.coalescingBinding(occ.serverId))
|
|
6592
|
+
return false;
|
|
6593
|
+
if (occ.event.lane === 'push') {
|
|
6594
|
+
if (!server.grant.has('pushEvents'))
|
|
6595
|
+
return false;
|
|
6596
|
+
try {
|
|
6597
|
+
this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet);
|
|
6598
|
+
}
|
|
6599
|
+
catch {
|
|
6600
|
+
return false;
|
|
6601
|
+
}
|
|
6602
|
+
}
|
|
6603
|
+
if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
|
|
6604
|
+
// §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
|
|
6605
|
+
if (!server.grant.has('channels.incoming'))
|
|
6606
|
+
return false;
|
|
6607
|
+
if (occ.event.lane === 'push')
|
|
6608
|
+
return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
|
|
6609
|
+
return !!this.channelRegistry?.getDescriptor(occ.scope.id);
|
|
6610
|
+
}
|
|
6611
|
+
return true;
|
|
6612
|
+
}
|
|
6613
|
+
async coalescedAudience(occ) {
|
|
6614
|
+
if (occ.deliverTo)
|
|
6615
|
+
return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
|
|
6616
|
+
const explicit = occ.event.event.targetAgents;
|
|
6617
|
+
if (explicit)
|
|
6618
|
+
return explicit.filter((n) => this.agents.has(n));
|
|
6619
|
+
if (occ.scope.kind === 'channel' && this.conversationRouter) {
|
|
6620
|
+
const router = this.conversationRouter;
|
|
6621
|
+
const bound = router.getBinding(occ.scope.id)?.agentName;
|
|
6622
|
+
if (bound)
|
|
6623
|
+
return this.agents.has(bound) ? [bound] : [];
|
|
6624
|
+
// No fork yet: the same bind decision an incoming message would get. A
|
|
6625
|
+
// batch that qualifies spawns its fork now so it has someone to wake.
|
|
6626
|
+
const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
|
|
6627
|
+
const decision = router.route({
|
|
6628
|
+
channelId: occ.scope.id,
|
|
6629
|
+
mentioned: metadata.mentioned === true,
|
|
6630
|
+
kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
|
|
6631
|
+
});
|
|
6632
|
+
if (decision.kind !== 'spawn')
|
|
6633
|
+
return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
|
|
6634
|
+
try {
|
|
6635
|
+
await this.createConversationAgent(decision.agentName, occ.scope.id);
|
|
6636
|
+
router.bind(occ.scope.id, decision.agentName, decision.generation);
|
|
6637
|
+
this.persistConversationRouterState();
|
|
6638
|
+
return [decision.agentName];
|
|
6639
|
+
}
|
|
6640
|
+
catch (err) {
|
|
6641
|
+
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
|
|
6642
|
+
return [];
|
|
6643
|
+
}
|
|
6644
|
+
}
|
|
6645
|
+
return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
6646
|
+
}
|
|
6647
|
+
/** Deliver through the lane's ordinary path; report where it landed. */
|
|
6648
|
+
async deliverCoalesced(occ, materialized, assemblingFor) {
|
|
6649
|
+
const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
6650
|
+
const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
|
|
6651
|
+
if (occ.event.lane === 'channel') {
|
|
6652
|
+
const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
|
|
6653
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
6654
|
+
await this.dispatchProcessEventToModules(event);
|
|
6655
|
+
return placement;
|
|
6656
|
+
}
|
|
6657
|
+
if (occ.scope.kind === 'channel') {
|
|
6658
|
+
// §3.2: a channel-scoped push addresses the channel's subject namespace,
|
|
6659
|
+
// so it is delivered as a message OF that channel — same routing
|
|
6660
|
+
// (conversation forks, tune-out, closed-channel invitation), same reply
|
|
6661
|
+
// locus, and the subject's stable platform identity (§3.1, vector 43).
|
|
6662
|
+
const push = occ.event.event;
|
|
6663
|
+
const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
|
|
6664
|
+
const origin = push.origin ?? {};
|
|
6665
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
6666
|
+
const event = {
|
|
6667
|
+
type: 'mcpl:channel-incoming',
|
|
6668
|
+
serverId: occ.serverId,
|
|
6669
|
+
channelId: occ.scope.id,
|
|
6670
|
+
messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
|
|
6671
|
+
threadId: identity.threadId ?? str(origin.threadId),
|
|
6672
|
+
author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
|
|
6673
|
+
content: materialized ? materialized.map(convertPushBlock) : push.content,
|
|
6674
|
+
timestamp: occ.timestamp,
|
|
6675
|
+
metadata: origin,
|
|
6676
|
+
tags: push.tags,
|
|
6677
|
+
triggerInference: assemblingFor ? false : push.triggerInference,
|
|
6678
|
+
targetAgents: push.targetAgents,
|
|
6679
|
+
coalescingSubject,
|
|
6680
|
+
eventId: occ.eventId,
|
|
6681
|
+
...narrowing,
|
|
6682
|
+
...(assemblingFor ? { assemblingFor } : {}),
|
|
6683
|
+
};
|
|
6684
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
6685
|
+
await this.dispatchProcessEventToModules(event);
|
|
6686
|
+
return placement;
|
|
6687
|
+
}
|
|
6688
|
+
const event = {
|
|
6689
|
+
...occ.event.event,
|
|
6690
|
+
eventId: occ.eventId,
|
|
6691
|
+
timestamp: occ.timestamp,
|
|
6692
|
+
...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
|
|
6693
|
+
coalescingSubject,
|
|
6694
|
+
// §5.2: the materialized occurrence is consumed by the request being
|
|
6695
|
+
// assembled; the wake that started it is not repeated.
|
|
6696
|
+
...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
|
|
6697
|
+
...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
|
|
6698
|
+
};
|
|
6699
|
+
const placement = this.handleMcplPushEvent(event);
|
|
6700
|
+
await this.dispatchProcessEventToModules(event);
|
|
6701
|
+
return placement;
|
|
6702
|
+
}
|
|
6703
|
+
async wakeForCoalescedBatch(occ) {
|
|
6704
|
+
const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
6705
|
+
this.cancelCoalescedWakes(subject);
|
|
6706
|
+
if (occ.event.lane !== 'push' || !occ.event.event.triggerInference)
|
|
6707
|
+
return;
|
|
6708
|
+
const origin = occ.event.event.origin;
|
|
6709
|
+
// The wake carries the batch's channel so the turn freezes its reply
|
|
6710
|
+
// locus there (the materialized delivery at assembly wakes nobody).
|
|
6711
|
+
const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
|
|
6712
|
+
const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
|
|
6713
|
+
for (const agentName of await this.coalescedAudience(occ)) {
|
|
6714
|
+
this.pendingRequests.push({
|
|
6715
|
+
agentName,
|
|
6716
|
+
reason: 'mcpl:push-event',
|
|
6717
|
+
source: occ.serverId,
|
|
6718
|
+
timestamp: Date.now(),
|
|
6719
|
+
channelId,
|
|
6720
|
+
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
6721
|
+
addressed: isAddressedMessage(occ.tags, origin),
|
|
6722
|
+
coalescingSubject: subject,
|
|
6723
|
+
});
|
|
6724
|
+
}
|
|
6725
|
+
}
|
|
6726
|
+
/** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
|
|
6727
|
+
cancelCoalescedWakes(subject) {
|
|
6728
|
+
this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
|
|
6729
|
+
for (const cooldown of this.providerAccelerationCooldowns.values()) {
|
|
6730
|
+
cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
|
|
6731
|
+
}
|
|
6732
|
+
}
|
|
6733
|
+
/**
|
|
6734
|
+
* A placement in the deferred queue is upgraded in place once the flush has
|
|
6735
|
+
* stored it: the stored copy carries `metadata.deferredWriteId`, found by
|
|
6736
|
+
* scanning the unread tail (above the watermark) — never below it.
|
|
6737
|
+
*/
|
|
6738
|
+
resolvePlacement(p) {
|
|
6739
|
+
if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId))
|
|
6740
|
+
return;
|
|
6741
|
+
const agent = this.agents.get(p.agent);
|
|
6742
|
+
if (!agent)
|
|
6743
|
+
return;
|
|
6744
|
+
const cm = agent.getContextManager();
|
|
6745
|
+
const watermark = agent.getConsumedWatermark();
|
|
6746
|
+
const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
|
|
6747
|
+
const all = cm.getAllMessages();
|
|
6748
|
+
for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
|
|
6749
|
+
if (all[i].metadata?.deferredWriteId === p.deferredId) {
|
|
6750
|
+
p.messageId = all[i].id;
|
|
6751
|
+
delete p.deferredId;
|
|
6752
|
+
return;
|
|
6753
|
+
}
|
|
6754
|
+
}
|
|
6755
|
+
}
|
|
6756
|
+
isUnreadPlacement(p) {
|
|
6757
|
+
this.resolvePlacement(p);
|
|
6758
|
+
if (p.deferredId)
|
|
6759
|
+
return this.deferredMessages.some((m) => m.id === p.deferredId);
|
|
6760
|
+
if (!p.messageId)
|
|
6761
|
+
return false;
|
|
6762
|
+
const agent = this.agents.get(p.agent);
|
|
6763
|
+
return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
|
|
6764
|
+
}
|
|
6765
|
+
/**
|
|
6766
|
+
* Unread = stored, above the agent's consumed watermark on the current
|
|
6767
|
+
* branch, not a body-group shard (immutable), and not folded into any
|
|
6768
|
+
* summary (compression is consumption, §3.3). Conservative on every doubt.
|
|
6769
|
+
*/
|
|
6770
|
+
isUnreadStoredMessage(agent, messageId) {
|
|
6771
|
+
const cm = agent.getContextManager();
|
|
6772
|
+
let message;
|
|
6773
|
+
try {
|
|
6774
|
+
message = cm.getMessage(messageId);
|
|
6775
|
+
}
|
|
6776
|
+
catch {
|
|
6777
|
+
return false;
|
|
6778
|
+
}
|
|
6779
|
+
if (!message || message.bodyGroupId)
|
|
6780
|
+
return false;
|
|
6781
|
+
// Every agent that can read this message must not have compiled past it.
|
|
6782
|
+
// Residents share one message slot, so a message one of them read is
|
|
6783
|
+
// history for all of them (the never-rewrite rule is about the message,
|
|
6784
|
+
// not the agent that happened to be asked). A tune-out-diverted message
|
|
6785
|
+
// never enters a resident's compiled view; only the subconscious reads
|
|
6786
|
+
// it through its merged view — no subconscious, nobody reads it.
|
|
6787
|
+
let readers;
|
|
6788
|
+
if (message.metadata?.tuneOut) {
|
|
6789
|
+
const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
|
|
6790
|
+
if (!subconscious)
|
|
6791
|
+
return true;
|
|
6792
|
+
readers = [subconscious];
|
|
6793
|
+
}
|
|
6794
|
+
else {
|
|
6795
|
+
readers = this.sharedSlotAgents.has(agent)
|
|
6796
|
+
? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
|
|
6797
|
+
: [agent];
|
|
6798
|
+
}
|
|
6799
|
+
const branch = cm.currentBranch().name;
|
|
6800
|
+
const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
|
|
6801
|
+
for (const reader of readers) {
|
|
6802
|
+
const watermark = reader.getConsumedWatermark();
|
|
6803
|
+
if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence)
|
|
6804
|
+
return false;
|
|
6805
|
+
// Compression is consumption (§3.3): folded into any reader's summary.
|
|
6806
|
+
try {
|
|
6807
|
+
const rcm = reader.getContextManager();
|
|
6808
|
+
const strategy = rcm.getStrategy();
|
|
6809
|
+
for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
|
|
6810
|
+
const entry = strategy.getSummary?.(summary.id);
|
|
6811
|
+
if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId))
|
|
6812
|
+
return false;
|
|
6813
|
+
}
|
|
6814
|
+
}
|
|
6815
|
+
catch {
|
|
6816
|
+
return false;
|
|
6817
|
+
}
|
|
6818
|
+
}
|
|
6819
|
+
return true;
|
|
6820
|
+
}
|
|
6821
|
+
removePlacement(p) {
|
|
6822
|
+
this.resolvePlacement(p);
|
|
6823
|
+
if (p.deferredId) {
|
|
6824
|
+
const before = this.deferredMessages.length;
|
|
6825
|
+
this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
|
|
6826
|
+
if (this.deferredMessages.length === before)
|
|
6827
|
+
return false;
|
|
6828
|
+
if (this.quiesced || this.deferredWritesPersisted)
|
|
6829
|
+
this.persistDeferredWrites();
|
|
6830
|
+
return true;
|
|
6831
|
+
}
|
|
6832
|
+
if (!p.messageId)
|
|
6833
|
+
return false;
|
|
6834
|
+
const agent = this.agents.get(p.agent);
|
|
6835
|
+
if (!agent)
|
|
6836
|
+
return false;
|
|
6837
|
+
try {
|
|
6838
|
+
agent.getContextManager().removeMessage(p.messageId);
|
|
6839
|
+
this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
|
|
6840
|
+
return true;
|
|
6841
|
+
}
|
|
6842
|
+
catch (err) {
|
|
6843
|
+
console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
|
|
6844
|
+
return false;
|
|
6845
|
+
}
|
|
6846
|
+
}
|
|
6148
6847
|
/**
|
|
6149
6848
|
* Convert an MCPL push event to a context message.
|
|
6150
6849
|
*/
|
|
@@ -6160,6 +6859,7 @@ export class AgentFramework {
|
|
|
6160
6859
|
eventId: event.eventId,
|
|
6161
6860
|
triggered: event.triggerInference ?? false,
|
|
6162
6861
|
...(event.tags ? { tags: event.tags } : {}),
|
|
6862
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6163
6863
|
};
|
|
6164
6864
|
// `origin.channelId` is the server-internal raw id (a bare Discord
|
|
6165
6865
|
// snowflake) — unroutable as a locus and unresolvable by the agent. Store
|
|
@@ -6189,8 +6889,23 @@ export class AgentFramework {
|
|
|
6189
6889
|
Object.assign(metadata, invitation.metadataPatch);
|
|
6190
6890
|
}
|
|
6191
6891
|
}
|
|
6192
|
-
|
|
6193
|
-
|
|
6892
|
+
// Silent heartbeat ticks are control-plane wakes, not autobiography.
|
|
6893
|
+
// Accept the no-message path only for the heartbeat feature's own exact
|
|
6894
|
+
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
6895
|
+
// content merely by setting `origin.silent`.
|
|
6896
|
+
const silentHeartbeat = event.serverId === 'heartbeat' &&
|
|
6897
|
+
event.featureSet === 'heartbeat' &&
|
|
6898
|
+
event.origin?.source === 'heartbeat' &&
|
|
6899
|
+
event.origin?.silent === true &&
|
|
6900
|
+
content.length === 0;
|
|
6901
|
+
const placement = { agent: '' };
|
|
6902
|
+
if (!silentHeartbeat) {
|
|
6903
|
+
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
6904
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
6905
|
+
}
|
|
6906
|
+
else {
|
|
6907
|
+
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
|
|
6908
|
+
}
|
|
6194
6909
|
if (event.triggerInference) {
|
|
6195
6910
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
6196
6911
|
const targetAgents = event.targetAgents
|
|
@@ -6206,9 +6921,16 @@ export class AgentFramework {
|
|
|
6206
6921
|
// they must outrank ambient chatter in a batched wake's locus
|
|
6207
6922
|
// selection just like their channels/incoming counterparts.
|
|
6208
6923
|
addressed: isAddressedMessage(event.tags, event.origin),
|
|
6924
|
+
...(silentHeartbeat ? {
|
|
6925
|
+
suppressProse: true,
|
|
6926
|
+
ephemeralSystemPrompt: '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
|
|
6927
|
+
'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
|
|
6928
|
+
} : {}),
|
|
6929
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6209
6930
|
});
|
|
6210
6931
|
}
|
|
6211
6932
|
}
|
|
6933
|
+
return placement.agent ? placement : undefined;
|
|
6212
6934
|
}
|
|
6213
6935
|
/**
|
|
6214
6936
|
* Derive the MCPL composite channel id (the outbound routing locus) for a
|
|
@@ -6353,7 +7075,9 @@ export class AgentFramework {
|
|
|
6353
7075
|
// route the resumed turn into the wrong channel.
|
|
6354
7076
|
const newestByKey = new Map();
|
|
6355
7077
|
for (const request of parked) {
|
|
6356
|
-
|
|
7078
|
+
// A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
|
|
7079
|
+
// must not absorb an unrelated wake that would vanish with it.
|
|
7080
|
+
const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
|
|
6357
7081
|
const prev = newestByKey.get(key);
|
|
6358
7082
|
if (!prev || request.timestamp >= prev.timestamp) {
|
|
6359
7083
|
newestByKey.set(key, request);
|
|
@@ -6479,8 +7203,13 @@ export class AgentFramework {
|
|
|
6479
7203
|
provReq = r;
|
|
6480
7204
|
}
|
|
6481
7205
|
}
|
|
7206
|
+
// A silent tick never suppresses a genuine coalesced wake. If any
|
|
7207
|
+
// ordinary request shares this batch, the ordinary turn semantics win.
|
|
7208
|
+
const silentOnly = requests.every((r) => r.suppressProse === true);
|
|
6482
7209
|
await this.startAgentStream(agent, {
|
|
6483
7210
|
...trigger,
|
|
7211
|
+
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
|
|
7212
|
+
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
|
|
6484
7213
|
channelId: channelReq?.channelId,
|
|
6485
7214
|
addressed: addressedReq !== undefined,
|
|
6486
7215
|
// A context-budget restart continues the same logical turn: it keeps
|
|
@@ -7008,6 +7737,9 @@ export class AgentFramework {
|
|
|
7008
7737
|
* driveStream (whose finally then owns clearing it).
|
|
7009
7738
|
*/
|
|
7010
7739
|
async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
|
|
7740
|
+
const continuingTurn = trigger?.reason === 'context_budget_restart'
|
|
7741
|
+
|| trigger?.reason === 'tool_result_guard_retry';
|
|
7742
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
7011
7743
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
7012
7744
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
7013
7745
|
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
@@ -7023,7 +7755,7 @@ export class AgentFramework {
|
|
|
7023
7755
|
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
7024
7756
|
// products — undoing the turn must not destroy them.
|
|
7025
7757
|
if (attempt === 0 &&
|
|
7026
|
-
|
|
7758
|
+
!continuingTurn &&
|
|
7027
7759
|
this.deferredMessages.length > 0) {
|
|
7028
7760
|
{
|
|
7029
7761
|
const deferred = this.drainDeferredFor(agent.name);
|
|
@@ -7043,8 +7775,20 @@ export class AgentFramework {
|
|
|
7043
7775
|
this.ackDeferredWrites();
|
|
7044
7776
|
}
|
|
7045
7777
|
}
|
|
7778
|
+
// RFC-006 §5: deferred batches for this agent render now, at the assembly
|
|
7779
|
+
// boundary — after the deferred flush (same window: turn alive, compile
|
|
7780
|
+
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
7781
|
+
// Not on a context-budget restart: that continues the same logical turn.
|
|
7782
|
+
if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
|
|
7783
|
+
try {
|
|
7784
|
+
await this.pushCoalescer.assemble(agent.name);
|
|
7785
|
+
}
|
|
7786
|
+
catch (err) {
|
|
7787
|
+
console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
|
|
7788
|
+
}
|
|
7789
|
+
}
|
|
7046
7790
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
7047
|
-
if (attempt === 0) {
|
|
7791
|
+
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
7048
7792
|
this.recordTurnCheckpoint(agent.name);
|
|
7049
7793
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
7050
7794
|
}
|
|
@@ -7061,7 +7805,7 @@ export class AgentFramework {
|
|
|
7061
7805
|
// after the restart fell through to the hours-old defaultPublishChannel.
|
|
7062
7806
|
// Keep the turn's trigger channel across restarts; only real new turns
|
|
7063
7807
|
// reset it.
|
|
7064
|
-
if (
|
|
7808
|
+
if (!continuingTurn) {
|
|
7065
7809
|
if (trigger?.channelId) {
|
|
7066
7810
|
this.activeTriggerChannels.set(agent.name, trigger.channelId);
|
|
7067
7811
|
}
|
|
@@ -7081,11 +7825,11 @@ export class AgentFramework {
|
|
|
7081
7825
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
7082
7826
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
7083
7827
|
// KV stability).
|
|
7084
|
-
if (
|
|
7828
|
+
if (continuingTurn) {
|
|
7085
7829
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
7086
7830
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
7087
7831
|
}
|
|
7088
|
-
if (
|
|
7832
|
+
if (!continuingTurn) {
|
|
7089
7833
|
if (attempt === 0)
|
|
7090
7834
|
this.maybePrimeProseMode(agent);
|
|
7091
7835
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
@@ -7104,9 +7848,9 @@ export class AgentFramework {
|
|
|
7104
7848
|
this.turnProseDeliveries.delete(agent.name);
|
|
7105
7849
|
this.turnProseSuppressed.delete(agent.name);
|
|
7106
7850
|
this.proseHybridSuppressed.delete(agent.name);
|
|
7107
|
-
if (
|
|
7851
|
+
if (turnProseRouting === 'hybrid')
|
|
7108
7852
|
this.proseTargetPins.delete(agent.name);
|
|
7109
|
-
if (
|
|
7853
|
+
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
7110
7854
|
// Explicit and disabled prose routing have no inferred locus.
|
|
7111
7855
|
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
7112
7856
|
// prose target at all. Neither mode freezes or announces a locus.
|
|
@@ -7126,6 +7870,14 @@ export class AgentFramework {
|
|
|
7126
7870
|
this.announceLocusIfChanged(agent.name, locus);
|
|
7127
7871
|
}
|
|
7128
7872
|
}
|
|
7873
|
+
// A context-budget restart normally preserves the turn locus, but a
|
|
7874
|
+
// silent control-plane wake must remain locusless even if an explicit tool
|
|
7875
|
+
// opened/pinned a channel mid-turn.
|
|
7876
|
+
if (trigger?.suppressProse) {
|
|
7877
|
+
this.turnLocusPins.delete(agent.name);
|
|
7878
|
+
this.proseTargetPins.delete(agent.name);
|
|
7879
|
+
this.proseContinuations.delete(agent.name);
|
|
7880
|
+
}
|
|
7129
7881
|
this.touchEphemeralRun(agent.name, true);
|
|
7130
7882
|
this.emitTrace({
|
|
7131
7883
|
type: 'inference:started',
|
|
@@ -7133,10 +7885,11 @@ export class AgentFramework {
|
|
|
7133
7885
|
// The turn-frozen locus was pinned just above (kept across a
|
|
7134
7886
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
7135
7887
|
channelId: this.turnLocusPins.get(agent.name),
|
|
7888
|
+
...(trigger?.suppressProse ? { silent: true } : {}),
|
|
7136
7889
|
});
|
|
7137
7890
|
// A budget restart continues the same logical inference window. The
|
|
7138
7891
|
// predecessor keeps EventGate liveness until its successor terminates.
|
|
7139
|
-
if (
|
|
7892
|
+
if (!continuingTurn) {
|
|
7140
7893
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
7141
7894
|
}
|
|
7142
7895
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
@@ -7149,19 +7902,14 @@ export class AgentFramework {
|
|
|
7149
7902
|
// Same channel choice as driveStream's own startTyping (which stays, is
|
|
7150
7903
|
// idempotent per channel, and owns the 7s refresh); the catch below stops
|
|
7151
7904
|
// it on the no-driveStream failure paths (e.g. a compile refusal).
|
|
7152
|
-
const earlyTypingChannel =
|
|
7905
|
+
const earlyTypingChannel = turnProseRouting === 'explicit'
|
|
7153
7906
|
? trigger?.channelId ?? null
|
|
7154
7907
|
: this.turnLocusPins.get(agent.name) ?? null;
|
|
7155
7908
|
if (earlyTypingChannel)
|
|
7156
7909
|
this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
7157
7910
|
try {
|
|
7158
7911
|
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
7159
|
-
const
|
|
7160
|
-
const tools = allTools.filter((t) => agent.canUseTool(t.name));
|
|
7161
|
-
// Explicit-mode agents get the on-demand routing reference (teach-by-
|
|
7162
|
-
// bounce: the grammar is never injected, only served when asked).
|
|
7163
|
-
if (agent.proseRouting === 'explicit')
|
|
7164
|
-
tools.push(PROSE_HELP_TOOL);
|
|
7912
|
+
const tools = this.agentToolSurface(agent, requestSnapshot);
|
|
7165
7913
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
7166
7914
|
// Both produce ContextInjection[] that get merged before inference.
|
|
7167
7915
|
let injections;
|
|
@@ -7192,6 +7940,14 @@ export class AgentFramework {
|
|
|
7192
7940
|
console.error('beforeInference hook error:', error);
|
|
7193
7941
|
}
|
|
7194
7942
|
}
|
|
7943
|
+
if (trigger?.ephemeralSystemPrompt) {
|
|
7944
|
+
const silentInjection = {
|
|
7945
|
+
namespace: 'framework:silent-heartbeat',
|
|
7946
|
+
position: 'system',
|
|
7947
|
+
content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
|
|
7948
|
+
};
|
|
7949
|
+
injections = injections ? [...injections, silentInjection] : [silentInjection];
|
|
7950
|
+
}
|
|
7195
7951
|
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
7196
7952
|
// Do not create a provider stream after its generation lost ownership.
|
|
7197
7953
|
if (this.agents.get(agent.name) !== agent) {
|
|
@@ -7291,6 +8047,7 @@ export class AgentFramework {
|
|
|
7291
8047
|
}
|
|
7292
8048
|
}
|
|
7293
8049
|
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
|
|
8050
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
7294
8051
|
const startTime = Date.now();
|
|
7295
8052
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
7296
8053
|
const myStreamId = agent.streamId;
|
|
@@ -7344,7 +8101,10 @@ export class AgentFramework {
|
|
|
7344
8101
|
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
7345
8102
|
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
7346
8103
|
// clears it, because the following prose is a reply to a new message.
|
|
7347
|
-
let turnSilenced =
|
|
8104
|
+
let turnSilenced = trigger?.suppressProse === true
|
|
8105
|
+
|| (trigger?.reason === 'tool_result_guard_retry'
|
|
8106
|
+
&& this.guardRetryTurnSilenced.get(agent.name) === true);
|
|
8107
|
+
this.guardRetryTurnSilenced.delete(agent.name);
|
|
7348
8108
|
// Live routing is only trusted when the membrane provides verbatim
|
|
7349
8109
|
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
7350
8110
|
// The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
|
|
@@ -7364,9 +8124,9 @@ export class AgentFramework {
|
|
|
7364
8124
|
// this doesn't violate never-guess: it says "attending to what you
|
|
7365
8125
|
// sent here", which is true regardless of where the reply goes.
|
|
7366
8126
|
// Heartbeat/no-trigger explicit turns show no indicator.
|
|
7367
|
-
const typingChannel =
|
|
8127
|
+
const typingChannel = turnProseRouting === 'disabled'
|
|
7368
8128
|
? null
|
|
7369
|
-
:
|
|
8129
|
+
: turnProseRouting === 'explicit'
|
|
7370
8130
|
? trigger?.channelId ?? null
|
|
7371
8131
|
: resolveTurnLocus();
|
|
7372
8132
|
if (typingChannel)
|
|
@@ -7383,6 +8143,9 @@ export class AgentFramework {
|
|
|
7383
8143
|
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
7384
8144
|
// finalized) instead of being appended to — see the 'retrying' case.
|
|
7385
8145
|
let outgoingInferenceId = newOutgoingInferenceId();
|
|
8146
|
+
// RFC-007: every inference id THIS stream mints, so the stream's end
|
|
8147
|
+
// aborts only its own open tool calls — never a successor's.
|
|
8148
|
+
const lifecycleInferenceIds = new Set([outgoingInferenceId]);
|
|
7386
8149
|
let outgoingIndex = 0;
|
|
7387
8150
|
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
7388
8151
|
// finally below (the one block every exit path shares). Which terminal
|
|
@@ -7402,10 +8165,10 @@ export class AgentFramework {
|
|
|
7402
8165
|
// turns until completion: otherwise speculative outgoing chunks could
|
|
7403
8166
|
// expose the wrapper before the fail-closed classifier runs.
|
|
7404
8167
|
const proseStream = this.channelRegistry &&
|
|
7405
|
-
|
|
8168
|
+
turnProseRouting !== 'disabled' &&
|
|
7406
8169
|
!agent.toolWrapperProseGuard
|
|
7407
8170
|
? new ProseStreamRouter({
|
|
7408
|
-
mode:
|
|
8171
|
+
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
7409
8172
|
initialTarget: typingChannel,
|
|
7410
8173
|
resolve: (spec) => {
|
|
7411
8174
|
const r = this.channelRegistry.resolveProseTarget(spec);
|
|
@@ -7418,6 +8181,23 @@ export class AgentFramework {
|
|
|
7418
8181
|
this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
|
|
7419
8182
|
}
|
|
7420
8183
|
};
|
|
8184
|
+
// Tool-result-guard publication boundary. While a SUBMITTED batch is
|
|
8185
|
+
// pending (or a guard recovery is running), a refusal can still abandon
|
|
8186
|
+
// this physical round, so its streamed text must not reach any surface
|
|
8187
|
+
// yet — neither the prose router nor the inference:tokens trace (TTS and
|
|
8188
|
+
// other trace consumers voice it). Hold both; release in order on a clean
|
|
8189
|
+
// round (tool-calls / non-guard completion), discard on a guard refusal.
|
|
8190
|
+
let guardHeld = [];
|
|
8191
|
+
const releaseGuardHeld = () => {
|
|
8192
|
+
const held = guardHeld;
|
|
8193
|
+
guardHeld = [];
|
|
8194
|
+
for (const item of held) {
|
|
8195
|
+
this.emitTrace(item.trace);
|
|
8196
|
+
if (proseStream && item.text !== undefined)
|
|
8197
|
+
emitOutgoing(proseStream.feed(item.text));
|
|
8198
|
+
}
|
|
8199
|
+
};
|
|
8200
|
+
const discardGuardHeld = () => { guardHeld = []; };
|
|
7421
8201
|
const adoptInjectedRound = () => {
|
|
7422
8202
|
if (!this.midTurnInputSignals.has(agent.name))
|
|
7423
8203
|
return;
|
|
@@ -7425,7 +8205,7 @@ export class AgentFramework {
|
|
|
7425
8205
|
// A new conversational message, not merely a tool result, means any
|
|
7426
8206
|
// earlier explicit delivery has completed its conversational job.
|
|
7427
8207
|
// Routing is NOT touched: the turn locus stays frozen.
|
|
7428
|
-
turnSilenced =
|
|
8208
|
+
turnSilenced = trigger?.suppressProse === true;
|
|
7429
8209
|
};
|
|
7430
8210
|
try {
|
|
7431
8211
|
for await (const event of stream) {
|
|
@@ -7439,8 +8219,8 @@ export class AgentFramework {
|
|
|
7439
8219
|
}
|
|
7440
8220
|
this.touchEphemeralRun(agent.name, true);
|
|
7441
8221
|
switch (event.type) {
|
|
7442
|
-
case 'tokens':
|
|
7443
|
-
|
|
8222
|
+
case 'tokens': {
|
|
8223
|
+
const trace = {
|
|
7444
8224
|
type: 'inference:tokens',
|
|
7445
8225
|
agentName: agent.name,
|
|
7446
8226
|
content: event.content,
|
|
@@ -7450,11 +8230,18 @@ export class AgentFramework {
|
|
|
7450
8230
|
// lets trace consumers tag every chunk with the channel this
|
|
7451
8231
|
// turn's prose is bound for, without re-deriving routing.
|
|
7452
8232
|
channelId: typingChannel ?? undefined,
|
|
7453
|
-
}
|
|
7454
|
-
|
|
7455
|
-
|
|
8233
|
+
};
|
|
8234
|
+
const text = event.meta.type === 'text' ? event.content : undefined;
|
|
8235
|
+
if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
|
|
8236
|
+
guardHeld.push({ trace, text });
|
|
8237
|
+
}
|
|
8238
|
+
else {
|
|
8239
|
+
this.emitTrace(trace);
|
|
8240
|
+
if (proseStream && text !== undefined)
|
|
8241
|
+
emitOutgoing(proseStream.feed(text));
|
|
7456
8242
|
}
|
|
7457
8243
|
break;
|
|
8244
|
+
}
|
|
7458
8245
|
case 'retrying': {
|
|
7459
8246
|
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
7460
8247
|
// RetryingEvent contract we must DISCARD everything this call has
|
|
@@ -7472,7 +8259,9 @@ export class AgentFramework {
|
|
|
7472
8259
|
(event.category ? ` category=${event.category}` : '') +
|
|
7473
8260
|
' — discarding the refused attempt');
|
|
7474
8261
|
outgoingInferenceId = newOutgoingInferenceId();
|
|
8262
|
+
lifecycleInferenceIds.add(outgoingInferenceId);
|
|
7475
8263
|
outgoingIndex = 0;
|
|
8264
|
+
discardGuardHeld();
|
|
7476
8265
|
proseStream?.reset();
|
|
7477
8266
|
this.emitTrace({
|
|
7478
8267
|
type: 'inference:tokens',
|
|
@@ -7497,6 +8286,10 @@ export class AgentFramework {
|
|
|
7497
8286
|
break;
|
|
7498
8287
|
}
|
|
7499
8288
|
case 'tool-calls': {
|
|
8289
|
+
// A tool-call response is a clean physical round: admit its
|
|
8290
|
+
// preceding results before storing/dispatching this new round.
|
|
8291
|
+
agent.toolResultGuard.accept();
|
|
8292
|
+
releaseGuardHeld();
|
|
7500
8293
|
adoptInjectedRound();
|
|
7501
8294
|
hadToolCalls = true;
|
|
7502
8295
|
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
@@ -7562,7 +8355,14 @@ export class AgentFramework {
|
|
|
7562
8355
|
// So all calls here have valid input — {} is legitimate for parameterless tools.
|
|
7563
8356
|
agent.enterWaitingForTools(event.calls, stream);
|
|
7564
8357
|
for (const call of event.calls) {
|
|
8358
|
+
// RFC-007: register at the one dispatch point for model-issued
|
|
8359
|
+
// calls, keyed by (agent, call id) with this inference's id;
|
|
8360
|
+
// `started` goes out once dispatch returns. Refusal sites inside
|
|
8361
|
+
// dispatch run synchronously and call refuse() first, so a call
|
|
8362
|
+
// the host refuses produces no events.
|
|
8363
|
+
this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
|
|
7565
8364
|
this.dispatchToolCall(agent.name, call);
|
|
8365
|
+
this.toolLifecycleEmitter?.open(agent.name, call.id);
|
|
7566
8366
|
}
|
|
7567
8367
|
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
7568
8368
|
// instead of batching every segment to the end of the turn. Long
|
|
@@ -7586,11 +8386,11 @@ export class AgentFramework {
|
|
|
7586
8386
|
liveProseRouting = true;
|
|
7587
8387
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
7588
8388
|
if (roundSegments.length > 0) {
|
|
7589
|
-
if (
|
|
8389
|
+
if (turnProseRouting === 'disabled') {
|
|
7590
8390
|
console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
|
|
7591
8391
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
7592
8392
|
}
|
|
7593
|
-
else if (
|
|
8393
|
+
else if (turnProseRouting === 'hybrid') {
|
|
7594
8394
|
if (turnSilenced) {
|
|
7595
8395
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
7596
8396
|
}
|
|
@@ -7603,7 +8403,7 @@ export class AgentFramework {
|
|
|
7603
8403
|
}
|
|
7604
8404
|
}
|
|
7605
8405
|
}
|
|
7606
|
-
else if (
|
|
8406
|
+
else if (turnProseRouting === 'explicit') {
|
|
7607
8407
|
// Explicit mode: every segment through the prose gateway.
|
|
7608
8408
|
// Silencing does not apply — unprefixed prose bounces and
|
|
7609
8409
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -7641,7 +8441,87 @@ export class AgentFramework {
|
|
|
7641
8441
|
case 'complete': {
|
|
7642
8442
|
adoptInjectedRound();
|
|
7643
8443
|
const durationMs = Date.now() - startTime;
|
|
7644
|
-
|
|
8444
|
+
let response = event.response;
|
|
8445
|
+
const guardCategory = response.raw?.response?.stop_details?.category ?? 'unknown';
|
|
8446
|
+
// A staged batch the strategy omitted from the request cannot
|
|
8447
|
+
// have caused this refusal: settle it and fall through to the
|
|
8448
|
+
// ordinary refusal handling (rewind/reaction) below.
|
|
8449
|
+
if (response.stopReason === 'refusal')
|
|
8450
|
+
agent.toolResultGuard.settleUnsubmitted(guardCategory);
|
|
8451
|
+
const guardRefusal = response.stopReason === 'refusal'
|
|
8452
|
+
&& (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
|
|
8453
|
+
if (guardRefusal) {
|
|
8454
|
+
discardGuardHeld();
|
|
8455
|
+
const category = guardCategory;
|
|
8456
|
+
const withheld = agent.toolResultGuard.withhold(category);
|
|
8457
|
+
// Nothing from the abandoned physical attempt is published or
|
|
8458
|
+
// persisted as assistant speech, including a terminal refusal
|
|
8459
|
+
// after the single recovery retry.
|
|
8460
|
+
proseStream?.reset();
|
|
8461
|
+
if (withheld) {
|
|
8462
|
+
const usage = response.details?.usage ?? response.usage;
|
|
8463
|
+
const tokenUsage = usage ? {
|
|
8464
|
+
input: usage.inputTokens, output: usage.outputTokens,
|
|
8465
|
+
cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
|
|
8466
|
+
} : undefined;
|
|
8467
|
+
this.noteRefusal(agent.name, category, tokenUsage);
|
|
8468
|
+
// The abandoned stream was billed (membrane usage here is
|
|
8469
|
+
// cumulative across this physical tool loop): count it before
|
|
8470
|
+
// the retry opens a fresh stream with its own usage.
|
|
8471
|
+
const abandonedUsage = response.details?.usage;
|
|
8472
|
+
if (abandonedUsage) {
|
|
8473
|
+
this.usageTracker.onInferenceCompleted(agent.name, {
|
|
8474
|
+
inputTokens: abandonedUsage.inputTokens,
|
|
8475
|
+
outputTokens: abandonedUsage.outputTokens,
|
|
8476
|
+
cacheCreationTokens: abandonedUsage.cacheCreationTokens,
|
|
8477
|
+
cacheReadTokens: abandonedUsage.cacheReadTokens,
|
|
8478
|
+
}, abandonedUsage.estimatedCost
|
|
8479
|
+
? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
|
|
8480
|
+
: undefined);
|
|
8481
|
+
this.persistUsageState();
|
|
8482
|
+
}
|
|
8483
|
+
this.logInference({
|
|
8484
|
+
timestamp: startTime, agentName: agent.name, requestId,
|
|
8485
|
+
success: false, error: 'Tool output withheld by the guard',
|
|
8486
|
+
request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
|
|
8487
|
+
});
|
|
8488
|
+
console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
|
|
8489
|
+
this.emitTrace({
|
|
8490
|
+
type: 'inference:stream_restarted', agentName: agent.name,
|
|
8491
|
+
reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
|
|
8492
|
+
budget: agent.maxStreamTokens,
|
|
8493
|
+
});
|
|
8494
|
+
await turnSpeechChain;
|
|
8495
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
8496
|
+
generationLost = true;
|
|
8497
|
+
lifecyclePhase = 'aborted';
|
|
8498
|
+
return;
|
|
8499
|
+
}
|
|
8500
|
+
agent.reset();
|
|
8501
|
+
preserveEventGateForSuccessor = true;
|
|
8502
|
+
lifecyclePhase = 'aborted';
|
|
8503
|
+
// Carry same-turn explicit-send suppression into the retry:
|
|
8504
|
+
// a text-only recovery after a successful send must not post
|
|
8505
|
+
// a postscript to a message already delivered.
|
|
8506
|
+
if (turnSilenced)
|
|
8507
|
+
this.guardRetryTurnSilenced.set(agent.name, true);
|
|
8508
|
+
// Restart inference inside this logical turn. No tool is
|
|
8509
|
+
// executed again and no settle/checkpoint/locus reset occurs.
|
|
8510
|
+
await this.startAgentStream(agent, {
|
|
8511
|
+
...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
|
|
8512
|
+
source: trigger?.source ?? 'framework', timestamp: Date.now(),
|
|
8513
|
+
}, attempt);
|
|
8514
|
+
return;
|
|
8515
|
+
}
|
|
8516
|
+
const lastResult = response.content.reduce((last, block, index) => block.type === 'tool_result' ? index : last, -1);
|
|
8517
|
+
response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
|
|
8518
|
+
agent.toolResultGuard.recovering = false;
|
|
8519
|
+
}
|
|
8520
|
+
else if (response.stopReason !== 'refusal') {
|
|
8521
|
+
agent.toolResultGuard.accept();
|
|
8522
|
+
}
|
|
8523
|
+
if (!guardRefusal)
|
|
8524
|
+
releaseGuardHeld();
|
|
7645
8525
|
// If the agent is still waiting_for_tools when 'complete' fires
|
|
7646
8526
|
// (shouldn't happen after incomplete-tool-call fix, but guard anyway),
|
|
7647
8527
|
// flush pending blocks + inject error results for any remaining pending tools.
|
|
@@ -7667,8 +8547,12 @@ export class AgentFramework {
|
|
|
7667
8547
|
// one door a giant blob still walks through).
|
|
7668
8548
|
const readyState = agent.state;
|
|
7669
8549
|
if (readyState.status === 'ready') {
|
|
7670
|
-
const
|
|
7671
|
-
|
|
8550
|
+
const cap = this.resolveToolResultInlineCap(agent).cap;
|
|
8551
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
|
|
8552
|
+
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
|
|
8553
|
+
// The response is already complete: this batch is never
|
|
8554
|
+
// submitted in this turn, so it cannot be refused. Admit it.
|
|
8555
|
+
agent.toolResultGuard.settleTurnEnded();
|
|
7672
8556
|
}
|
|
7673
8557
|
}
|
|
7674
8558
|
// Add assistant response to context.
|
|
@@ -7855,7 +8739,7 @@ export class AgentFramework {
|
|
|
7855
8739
|
`(remaining ${forced.remaining})`);
|
|
7856
8740
|
}, () => this.finishUnstick(agent.name, false, category));
|
|
7857
8741
|
}
|
|
7858
|
-
else if (rh?.autoRewind) {
|
|
8742
|
+
else if (rh?.autoRewind && !guardRefusal) {
|
|
7859
8743
|
const cap = Math.max(1, rh.maxRewinds ?? 3);
|
|
7860
8744
|
const used = this.refusalRewinds.get(agent.name) ?? 0;
|
|
7861
8745
|
doRewind(used < cap, (count) => {
|
|
@@ -7894,7 +8778,7 @@ export class AgentFramework {
|
|
|
7894
8778
|
this.refusalStreak.delete(agent.name);
|
|
7895
8779
|
}
|
|
7896
8780
|
// Dispatch speech (and thoughts if any)
|
|
7897
|
-
if (speechContent.length > 0 || thoughts.length > 0) {
|
|
8781
|
+
if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
|
|
7898
8782
|
const speechContext = {
|
|
7899
8783
|
turnComplete: true,
|
|
7900
8784
|
trigger: trigger ?? {
|
|
@@ -7920,11 +8804,19 @@ export class AgentFramework {
|
|
|
7920
8804
|
.join('\n')
|
|
7921
8805
|
.trim();
|
|
7922
8806
|
if (speechText) {
|
|
7923
|
-
if (
|
|
8807
|
+
if (turnProseRouting === 'disabled') {
|
|
7924
8808
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
7925
8809
|
this.recordProseSuppression(agent.name, 1);
|
|
7926
8810
|
}
|
|
7927
|
-
else if (
|
|
8811
|
+
else if (turnSilenced && turnProseRouting !== 'explicit') {
|
|
8812
|
+
// Only reachable on a guard recovery that carried the
|
|
8813
|
+
// same-turn send suppression, or a suppressProse trigger
|
|
8814
|
+
// (a fresh text-only turn starts unsilenced). Explicit
|
|
8815
|
+
// mode is exempt, as mid-turn.
|
|
8816
|
+
console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
|
|
8817
|
+
this.recordProseSuppression(agent.name, 1);
|
|
8818
|
+
}
|
|
8819
|
+
else if (turnProseRouting === 'hybrid') {
|
|
7928
8820
|
const locus = resolveTurnLocus();
|
|
7929
8821
|
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
7930
8822
|
try {
|
|
@@ -7934,7 +8826,7 @@ export class AgentFramework {
|
|
|
7934
8826
|
console.error('text-only hybrid prose delivery failed:', err);
|
|
7935
8827
|
}
|
|
7936
8828
|
}
|
|
7937
|
-
else if (
|
|
8829
|
+
else if (turnProseRouting === 'explicit') {
|
|
7938
8830
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
7939
8831
|
try {
|
|
7940
8832
|
await this.deliverProse(agent, speechText);
|
|
@@ -7989,13 +8881,13 @@ export class AgentFramework {
|
|
|
7989
8881
|
// land before the trailing prose. Awaited even when silenced —
|
|
7990
8882
|
// the chain may still be flushing earlier rounds' posts.
|
|
7991
8883
|
await turnSpeechChain;
|
|
7992
|
-
if (
|
|
8884
|
+
if (turnProseRouting === 'disabled') {
|
|
7993
8885
|
if (segments.length > 0) {
|
|
7994
8886
|
console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
|
|
7995
8887
|
this.recordProseSuppression(agent.name, segments.length);
|
|
7996
8888
|
}
|
|
7997
8889
|
}
|
|
7998
|
-
else if (
|
|
8890
|
+
else if (turnProseRouting === 'hybrid') {
|
|
7999
8891
|
if (silenced && segments.length > 0) {
|
|
8000
8892
|
this.recordProseSuppression(agent.name, segments.length);
|
|
8001
8893
|
}
|
|
@@ -8011,7 +8903,7 @@ export class AgentFramework {
|
|
|
8011
8903
|
}
|
|
8012
8904
|
}
|
|
8013
8905
|
}
|
|
8014
|
-
else if (
|
|
8906
|
+
else if (turnProseRouting === 'explicit') {
|
|
8015
8907
|
if (segments.length > 0) {
|
|
8016
8908
|
console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
|
|
8017
8909
|
for (const seg of segments) {
|
|
@@ -8061,7 +8953,7 @@ export class AgentFramework {
|
|
|
8061
8953
|
// awaited before trailing dispatch, and trailing/text-only
|
|
8062
8954
|
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
8063
8955
|
// envelopes acknowledge themselves through the prose gateway.
|
|
8064
|
-
if (
|
|
8956
|
+
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
8065
8957
|
this.appendProseDeliveryReceipt(agent);
|
|
8066
8958
|
}
|
|
8067
8959
|
// Explicit-prose `!` continuation: a prose segment this turn asked
|
|
@@ -8225,7 +9117,7 @@ export class AgentFramework {
|
|
|
8225
9117
|
// deliveries map persists (cleared only at fresh turn
|
|
8226
9118
|
// start) and the continuation stream's end writes ONE
|
|
8227
9119
|
// receipt for the whole turn.
|
|
8228
|
-
if (cancelKind === 'turn_ended' &&
|
|
9120
|
+
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
8229
9121
|
await turnSpeechChain;
|
|
8230
9122
|
this.appendProseDeliveryReceipt(agent);
|
|
8231
9123
|
}
|
|
@@ -8430,6 +9322,13 @@ export class AgentFramework {
|
|
|
8430
9322
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
8431
9323
|
// crash emits nothing — that is the documented best-effort limit, and
|
|
8432
9324
|
// why consumers keep their safety timeout.
|
|
9325
|
+
//
|
|
9326
|
+
// RFC-007 §7.3: a tool call still open when its turn's stream ends was
|
|
9327
|
+
// cancelled before a result — its terminal is `aborted`. (A normal turn
|
|
9328
|
+
// has none: the stream waits for every result before continuing.)
|
|
9329
|
+
// Scoped to this stream's own inference ids: a budget-restart successor
|
|
9330
|
+
// may already be running for the same agent.
|
|
9331
|
+
this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
|
|
8433
9332
|
this.hookOrchestrator?.emitLifecycle({
|
|
8434
9333
|
inferenceId: outgoingInferenceId,
|
|
8435
9334
|
conversationId: agent.name,
|
|
@@ -8444,6 +9343,23 @@ export class AgentFramework {
|
|
|
8444
9343
|
// (typing still stops, compression still runs — matching the observed
|
|
8445
9344
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
8446
9345
|
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
9346
|
+
// A frame that ends without handing off to a successor (abort,
|
|
9347
|
+
// exhausted errors) must not leave its batch pending: a later turn
|
|
9348
|
+
// would resubmit the originals and claim its own refusal. Successor
|
|
9349
|
+
// frames (error-policy retry, budget/guard restart) bump streamId or
|
|
9350
|
+
// set preserveEventGateForSuccessor and keep the batch.
|
|
9351
|
+
if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
|
|
9352
|
+
// abandon() clears the batch before its audit append, so a failed
|
|
9353
|
+
// write cannot re-arm it; it must not abort the rest of teardown
|
|
9354
|
+
// (gate release, turn token, provider admission) either.
|
|
9355
|
+
try {
|
|
9356
|
+
agent.toolResultGuard.abandon('aborted');
|
|
9357
|
+
}
|
|
9358
|
+
catch (error) {
|
|
9359
|
+
console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
|
|
9360
|
+
}
|
|
9361
|
+
}
|
|
9362
|
+
agent.toolResultGuard.recovering = false;
|
|
8447
9363
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
8448
9364
|
}
|
|
8449
9365
|
if (!generationLost && ownsPhysicalStream) {
|
|
@@ -8756,6 +9672,7 @@ export class AgentFramework {
|
|
|
8756
9672
|
const startTime = Date.now();
|
|
8757
9673
|
this.runCodeExecution(agentName, call)
|
|
8758
9674
|
.catch((error) => {
|
|
9675
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
8759
9676
|
// runCodeExecution is designed not to reject; this is the last-resort
|
|
8760
9677
|
// guard so a bug here can never strand the agent in waiting_for_tools.
|
|
8761
9678
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -8845,21 +9762,41 @@ export class AgentFramework {
|
|
|
8845
9762
|
isError: true,
|
|
8846
9763
|
};
|
|
8847
9764
|
}
|
|
9765
|
+
// A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
|
|
9766
|
+
if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
|
|
9767
|
+
return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
|
|
9768
|
+
}
|
|
9769
|
+
const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
|
|
9770
|
+
const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
|
|
9771
|
+
const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
|
|
9772
|
+
const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
|
|
9773
|
+
const capNote = requestedMs !== undefined && requestedMs > ceilingMs
|
|
9774
|
+
? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
|
|
9775
|
+
: undefined;
|
|
8848
9776
|
const agent = this.agents.get(agentName);
|
|
8849
9777
|
const surface = agent
|
|
8850
9778
|
? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
|
|
8851
9779
|
: this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
|
|
8852
9780
|
const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
|
|
8853
9781
|
if (input.background === true) {
|
|
8854
|
-
|
|
9782
|
+
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
|
|
9783
|
+
if (capNote && started.success && started.data && typeof started.data === 'object') {
|
|
9784
|
+
started.data.time_limit_note = capNote;
|
|
9785
|
+
}
|
|
9786
|
+
return started;
|
|
8855
9787
|
}
|
|
8856
9788
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
8857
9789
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
8858
|
-
const exec = await runner.exec(input.code, injected);
|
|
9790
|
+
const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
|
|
8859
9791
|
const endTurn = this.scriptDeferredEndTurn.delete(agentName);
|
|
8860
9792
|
return {
|
|
8861
9793
|
success: true,
|
|
8862
|
-
data: {
|
|
9794
|
+
data: {
|
|
9795
|
+
stdout: exec.stdout,
|
|
9796
|
+
stderr: exec.stderr,
|
|
9797
|
+
return_code: exec.returnCode,
|
|
9798
|
+
...(capNote ? { time_limit_note: capNote } : {}),
|
|
9799
|
+
},
|
|
8863
9800
|
isError: false,
|
|
8864
9801
|
...(endTurn ? { endTurn: true } : {}),
|
|
8865
9802
|
};
|
|
@@ -8875,7 +9812,7 @@ export class AgentFramework {
|
|
|
8875
9812
|
* limitation is stated in the tool description so agents can calibrate
|
|
8876
9813
|
* how much to rely on an armed wake.
|
|
8877
9814
|
*/
|
|
8878
|
-
startBackgroundScript(agentName, code, injected) {
|
|
9815
|
+
startBackgroundScript(agentName, code, injected, timeLimitMs) {
|
|
8879
9816
|
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
8880
9817
|
// would outlive its owner and its wake would land in the primary
|
|
8881
9818
|
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
@@ -8899,7 +9836,8 @@ export class AgentFramework {
|
|
|
8899
9836
|
};
|
|
8900
9837
|
}
|
|
8901
9838
|
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
8902
|
-
|
|
9839
|
+
// The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
|
|
9840
|
+
const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
8903
9841
|
// Journal: a file under the agent's first read-write workspace mount so
|
|
8904
9842
|
// their existing read/grep/shell tools work on it. Python appends
|
|
8905
9843
|
// directly to the materialized path; watch-mode mounts sync it back to
|
|
@@ -8960,6 +9898,7 @@ export class AgentFramework {
|
|
|
8960
9898
|
script_id: scriptId,
|
|
8961
9899
|
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
8962
9900
|
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
9901
|
+
lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
|
|
8963
9902
|
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
8964
9903
|
},
|
|
8965
9904
|
};
|
|
@@ -9532,17 +10471,96 @@ export class AgentFramework {
|
|
|
9532
10471
|
// Append one record per event — same rationale as logInference.
|
|
9533
10472
|
this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
|
|
9534
10473
|
}
|
|
10474
|
+
/** Forget one server's RFC-008 class declarations (see the close handler). */
|
|
10475
|
+
dropMcplToolClasses(serverId) {
|
|
10476
|
+
// Tolerate partially-constructed frameworks (tests build them with
|
|
10477
|
+
// Object.create): nothing cached means nothing to forget.
|
|
10478
|
+
if (!this.mcplToolClasses?.size)
|
|
10479
|
+
return;
|
|
10480
|
+
const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
|
|
10481
|
+
for (const name of [...this.mcplToolClasses.keys()]) {
|
|
10482
|
+
if (name.startsWith(`${prefix}--`))
|
|
10483
|
+
this.mcplToolClasses.delete(name);
|
|
10484
|
+
}
|
|
10485
|
+
}
|
|
9535
10486
|
/**
|
|
9536
|
-
*
|
|
9537
|
-
*
|
|
10487
|
+
* RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
|
|
10488
|
+
* its effective class, and for MCPL tools the providing connection and
|
|
10489
|
+
* the server's own tool name. Host knowledge (built-in table, embedding
|
|
10490
|
+
* host's table) applies only to tools the host implements; an MCPL tool's
|
|
10491
|
+
* class comes from an operator override or its server's declaration.
|
|
10492
|
+
*/
|
|
10493
|
+
describeToolForLifecycle(tool) {
|
|
10494
|
+
const mcpl = this.resolveMcplTool(tool);
|
|
10495
|
+
const { classes } = this.effectiveToolClass(tool, mcpl !== null);
|
|
10496
|
+
if (mcpl)
|
|
10497
|
+
return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
|
|
10498
|
+
return { class: classes };
|
|
10499
|
+
}
|
|
10500
|
+
/**
|
|
10501
|
+
* RFC-008 §5.1: a tool's effective class and the source that decided it.
|
|
10502
|
+
* Operator overrides first; then host knowledge, for tools the host
|
|
10503
|
+
* implements (never for MCPL tools); then an MCPL tool's own declaration.
|
|
10504
|
+
* Tolerates partially-constructed frameworks (tests use Object.create).
|
|
10505
|
+
*/
|
|
10506
|
+
effectiveToolClass(tool, isMcpl) {
|
|
10507
|
+
return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
|
|
10508
|
+
overrides: this.toolClassOverrides ?? [],
|
|
10509
|
+
host: isMcpl ? [] : (this.hostToolClasses ?? []),
|
|
10510
|
+
});
|
|
10511
|
+
}
|
|
10512
|
+
/**
|
|
10513
|
+
* RFC-008 §6: tools with their effective class and where that class came
|
|
10514
|
+
* from (`override`, `host`, `server`, or `none` for an unclassed tool,
|
|
10515
|
+
* which policy treats as most restrictive). For operators: a surprising
|
|
10516
|
+
* class should be visible before it matters.
|
|
10517
|
+
*
|
|
10518
|
+
* With `agentName`, exactly the tools that agent is shown. Without, every
|
|
10519
|
+
* tool the framework offers to anyone: the shared board plus each agent's
|
|
10520
|
+
* own (the subconscious's surface, `prose_help`), each listed once.
|
|
10521
|
+
*/
|
|
10522
|
+
listToolClasses(agentName) {
|
|
10523
|
+
let tools;
|
|
10524
|
+
if (agentName !== undefined) {
|
|
10525
|
+
const agent = this.agents.get(agentName);
|
|
10526
|
+
if (!agent)
|
|
10527
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
10528
|
+
tools = this.agentToolSurface(agent);
|
|
10529
|
+
}
|
|
10530
|
+
else {
|
|
10531
|
+
tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
|
|
10532
|
+
}
|
|
10533
|
+
const seen = new Set();
|
|
10534
|
+
const listed = [];
|
|
10535
|
+
for (const t of tools) {
|
|
10536
|
+
if (seen.has(t.name))
|
|
10537
|
+
continue;
|
|
10538
|
+
seen.add(t.name);
|
|
10539
|
+
const mcpl = this.resolveMcplTool(t.name);
|
|
10540
|
+
const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
|
|
10541
|
+
listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
|
|
10542
|
+
}
|
|
10543
|
+
return listed;
|
|
10544
|
+
}
|
|
10545
|
+
/**
|
|
10546
|
+
* Find the MCPL server for a tool: [serverId, prefix], or null.
|
|
10547
|
+
* Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
|
|
10548
|
+
* both. The server whose tools/list produced the name decides; a name no
|
|
10549
|
+
* live server listed (stale, or invented by the model) goes to the longest
|
|
10550
|
+
* matching prefix. Never prefix-map insertion order.
|
|
9538
10551
|
*/
|
|
9539
10552
|
resolveMcplTool(toolName) {
|
|
9540
|
-
|
|
9541
|
-
|
|
10553
|
+
const listedBy = this.mcplToolServers?.get(toolName);
|
|
10554
|
+
let best = null;
|
|
10555
|
+
for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
|
|
10556
|
+
if (!toolName.startsWith(prefix + '--'))
|
|
10557
|
+
continue;
|
|
10558
|
+
if (serverId === listedBy)
|
|
9542
10559
|
return [serverId, prefix];
|
|
9543
|
-
|
|
10560
|
+
if (!best || prefix.length > best[1].length)
|
|
10561
|
+
best = [serverId, prefix];
|
|
9544
10562
|
}
|
|
9545
|
-
return
|
|
10563
|
+
return best;
|
|
9546
10564
|
}
|
|
9547
10565
|
dispatchToolCall(agentName, call) {
|
|
9548
10566
|
// Enrich call with caller identity so modules can resolve the calling agent
|
|
@@ -9712,6 +10730,8 @@ export class AgentFramework {
|
|
|
9712
10730
|
})
|
|
9713
10731
|
.catch((error) => {
|
|
9714
10732
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
10733
|
+
// RFC-007: the module threw — the host could not obtain a result.
|
|
10734
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
9715
10735
|
this.emitTrace({
|
|
9716
10736
|
type: 'tool:failed',
|
|
9717
10737
|
module: moduleName,
|
|
@@ -9777,6 +10797,7 @@ export class AgentFramework {
|
|
|
9777
10797
|
? this.pendingAssistantBlocks.has(agent.name)
|
|
9778
10798
|
: this.pendingAssistantBlocks.size > 0;
|
|
9779
10799
|
if (!hasToolResult &&
|
|
10800
|
+
opts?.bypassDeferralFor !== agent.name &&
|
|
9780
10801
|
(this.quiesced ||
|
|
9781
10802
|
midToolCycle ||
|
|
9782
10803
|
this.activeTurnTokens.has(agent.name) ||
|
|
@@ -9793,9 +10814,18 @@ export class AgentFramework {
|
|
|
9793
10814
|
this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
|
|
9794
10815
|
if (this.quiesced || this.deferredWritesPersisted)
|
|
9795
10816
|
this.persistDeferredWrites();
|
|
10817
|
+
if (opts?.placement) {
|
|
10818
|
+
opts.placement.agent = agent.name;
|
|
10819
|
+
opts.placement.deferredId = id;
|
|
10820
|
+
}
|
|
9796
10821
|
return ''; // Deferred — flushed at the target's next boundary
|
|
9797
10822
|
}
|
|
9798
|
-
|
|
10823
|
+
const stored = agent.getContextManager().addMessage(participant, content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata);
|
|
10824
|
+
if (opts?.placement) {
|
|
10825
|
+
opts.placement.agent = agent.name;
|
|
10826
|
+
opts.placement.messageId = stored;
|
|
10827
|
+
}
|
|
10828
|
+
return stored;
|
|
9799
10829
|
}
|
|
9800
10830
|
/**
|
|
9801
10831
|
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
@@ -10239,6 +11269,11 @@ export class AgentFramework {
|
|
|
10239
11269
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
10240
11270
|
this.featureSetManager = new FeatureSetManager();
|
|
10241
11271
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
11272
|
+
this.toolLifecycleEmitter = new ToolLifecycleEmitter({
|
|
11273
|
+
observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
|
|
11274
|
+
configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
|
|
11275
|
+
describe: (tool) => this.describeToolForLifecycle(tool),
|
|
11276
|
+
});
|
|
10242
11277
|
// Build prefix map and store configs for tool routing
|
|
10243
11278
|
for (const config of serverConfigs) {
|
|
10244
11279
|
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
|
|
@@ -10250,7 +11285,8 @@ export class AgentFramework {
|
|
|
10250
11285
|
const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
|
|
10251
11286
|
?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
|
|
10252
11287
|
// Push events handler (Step 6)
|
|
10253
|
-
this.
|
|
11288
|
+
this.initializePushCoalescer();
|
|
11289
|
+
this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event));
|
|
10254
11290
|
// Server-initiated inference router (Step 6)
|
|
10255
11291
|
this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
|
|
10256
11292
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
@@ -10261,6 +11297,7 @@ export class AgentFramework {
|
|
|
10261
11297
|
// Channel registry (Step 7)
|
|
10262
11298
|
this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
|
|
10263
11299
|
store: this.store,
|
|
11300
|
+
handleCoalescedIncoming: (serverId, message, event) => this.handleCoalescedIncoming(serverId, message, event),
|
|
10264
11301
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
10265
11302
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
10266
11303
|
if (server) {
|
|
@@ -10329,11 +11366,17 @@ export class AgentFramework {
|
|
|
10329
11366
|
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
10330
11367
|
this.mcplHostCapabilities = {
|
|
10331
11368
|
version: '0.5',
|
|
11369
|
+
// RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
|
|
11370
|
+
// channel-scoped pushes, one-hour retry window.
|
|
11371
|
+
eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
|
|
10332
11372
|
pushEvents: true,
|
|
10333
11373
|
contextHooks: {
|
|
10334
11374
|
beforeInference: true,
|
|
10335
11375
|
},
|
|
10336
11376
|
inferenceLifecycle: true,
|
|
11377
|
+
// RFC-007: tools/lifecycle (metadata, and requested argument fields
|
|
11378
|
+
// under their own grant) plus the tools/observe filter.
|
|
11379
|
+
toolLifecycle: { observe: true, inputs: true },
|
|
10337
11380
|
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
10338
11381
|
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
10339
11382
|
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
@@ -11066,17 +12109,28 @@ export class AgentFramework {
|
|
|
11066
12109
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
11067
12110
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
11068
12111
|
const connected = connection?.isConnected ?? false;
|
|
12112
|
+
// Attribute through the dispatch resolver: a bare prefix match would
|
|
12113
|
+
// also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
|
|
12114
|
+
const ownTools = (this.mcplTools ?? []).flatMap((t) => {
|
|
12115
|
+
const hit = this.resolveMcplTool(t.name);
|
|
12116
|
+
return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
|
|
12117
|
+
});
|
|
11069
12118
|
result.push({
|
|
11070
12119
|
id,
|
|
11071
12120
|
connected,
|
|
11072
12121
|
retrying: !connected && (connection?.willReconnect ?? false),
|
|
11073
12122
|
toolPrefix: prefix,
|
|
11074
|
-
toolCount:
|
|
12123
|
+
toolCount: ownTools.length,
|
|
11075
12124
|
policyEstablished: connection?.policyEstablished ?? false,
|
|
11076
12125
|
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
11077
12126
|
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
11078
12127
|
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
11079
12128
|
allowHostCommands: config.allowHostCommands === true,
|
|
12129
|
+
toolObserveFilter: connection?.toolObserveFilter ?? null,
|
|
12130
|
+
toolClasses: ownTools.map(({ tool, serverTool }) => {
|
|
12131
|
+
const { classes, source } = this.effectiveToolClass(tool, true);
|
|
12132
|
+
return { tool, serverTool, class: classes, source };
|
|
12133
|
+
}),
|
|
11080
12134
|
manifestState: connection
|
|
11081
12135
|
? { ...connection.manifestState }
|
|
11082
12136
|
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
@@ -11109,10 +12163,15 @@ export class AgentFramework {
|
|
|
11109
12163
|
const barrier = this.discordAwarenessBarrier;
|
|
11110
12164
|
if (barrier)
|
|
11111
12165
|
await barrier.promise;
|
|
11112
|
-
this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
12166
|
+
await this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
11113
12167
|
});
|
|
11114
12168
|
// Handle server-initiated inference requests (Step 6)
|
|
11115
12169
|
connection.on('inference-request', async (params, responder) => {
|
|
12170
|
+
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
|
|
12171
|
+
if (this.pushCoalescer?.isRendering(connection.id)) {
|
|
12172
|
+
responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
|
|
12173
|
+
return;
|
|
12174
|
+
}
|
|
11116
12175
|
if (this.inferenceRouter && responder) {
|
|
11117
12176
|
const barrier = this.discordAwarenessBarrier;
|
|
11118
12177
|
if (barrier)
|
|
@@ -11150,6 +12209,21 @@ export class AgentFramework {
|
|
|
11150
12209
|
channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
|
|
11151
12210
|
});
|
|
11152
12211
|
});
|
|
12212
|
+
// RFC-007 §6: the server's filter over tools/lifecycle. Admission already
|
|
12213
|
+
// required toolLifecycle.observe (-32002 otherwise). Validation failures
|
|
12214
|
+
// leave the previous filter in force (§6.6).
|
|
12215
|
+
connection.on('tools-observe', (params, responder) => {
|
|
12216
|
+
const parsed = parseToolObserveParams(params);
|
|
12217
|
+
if (!parsed.ok) {
|
|
12218
|
+
if (responder?.respondError)
|
|
12219
|
+
responder.respondError(-32602, parsed.message, parsed.data);
|
|
12220
|
+
else
|
|
12221
|
+
console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
|
|
12222
|
+
return;
|
|
12223
|
+
}
|
|
12224
|
+
connection.toolObserveFilter = parsed.rules;
|
|
12225
|
+
responder?.respond({});
|
|
12226
|
+
});
|
|
11153
12227
|
connection.on('model-info', (_params, responder) => {
|
|
11154
12228
|
// §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
|
|
11155
12229
|
// in the DIRECT result shape, and numeric contextWindow cannot express
|
|
@@ -11164,7 +12238,7 @@ export class AgentFramework {
|
|
|
11164
12238
|
const barrier = this.discordAwarenessBarrier;
|
|
11165
12239
|
if (barrier)
|
|
11166
12240
|
await barrier.promise;
|
|
11167
|
-
this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
12241
|
+
await this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
11168
12242
|
});
|
|
11169
12243
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
11170
12244
|
connection.on('host-command', async (params, responder) => {
|
|
@@ -11300,6 +12374,10 @@ export class AgentFramework {
|
|
|
11300
12374
|
// disconnectMcplServer, which deletes the trees explicitly.
|
|
11301
12375
|
connection.on('close', (code, signal) => {
|
|
11302
12376
|
this.featureSetManager?.removeServer(connection.id);
|
|
12377
|
+
// RFC-008: a closed provider's class hints are stale — on reconnect it
|
|
12378
|
+
// may class its tools differently. Until the next tools/list its tools
|
|
12379
|
+
// are unclassed, the restrictive answer (RFC-007 §4.3).
|
|
12380
|
+
this.dropMcplToolClasses(connection.id);
|
|
11303
12381
|
this.emitTrace({
|
|
11304
12382
|
type: 'mcpl:server-closed',
|
|
11305
12383
|
serverId: connection.id,
|
|
@@ -11319,6 +12397,8 @@ export class AgentFramework {
|
|
|
11319
12397
|
return;
|
|
11320
12398
|
const tools = [];
|
|
11321
12399
|
const toolFeatureSets = new Map();
|
|
12400
|
+
const toolClasses = new Map();
|
|
12401
|
+
const toolServers = new Map();
|
|
11322
12402
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
11323
12403
|
const config = this.mcplServerConfigs.get(server.id);
|
|
11324
12404
|
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
@@ -11328,7 +12408,13 @@ export class AgentFramework {
|
|
|
11328
12408
|
if (!isToolAllowed(tool.name, config))
|
|
11329
12409
|
continue;
|
|
11330
12410
|
const namespacedName = `${prefix}--${tool.name}`;
|
|
12411
|
+
toolServers.set(namespacedName, server.id);
|
|
11331
12412
|
const attributedTool = tool;
|
|
12413
|
+
// RFC-008: the server's class hint for this tool. Never shown to
|
|
12414
|
+
// the model (the definition below is unchanged by it).
|
|
12415
|
+
const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
|
|
12416
|
+
if (declaredClasses)
|
|
12417
|
+
toolClasses.set(namespacedName, declaredClasses);
|
|
11332
12418
|
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
11333
12419
|
? attributedTool.featureSet
|
|
11334
12420
|
: typeof attributedTool._meta?.featureSet === 'string'
|
|
@@ -11352,6 +12438,8 @@ export class AgentFramework {
|
|
|
11352
12438
|
}
|
|
11353
12439
|
this.mcplTools = tools;
|
|
11354
12440
|
this.mcplToolFeatureSets = toolFeatureSets;
|
|
12441
|
+
this.mcplToolClasses = toolClasses;
|
|
12442
|
+
this.mcplToolServers = toolServers;
|
|
11355
12443
|
}
|
|
11356
12444
|
/**
|
|
11357
12445
|
* Handle a tools/list_changed notification with collapse logic.
|
|
@@ -11409,6 +12497,8 @@ export class AgentFramework {
|
|
|
11409
12497
|
const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--"
|
|
11410
12498
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
11411
12499
|
if (!server) {
|
|
12500
|
+
// RFC-007: refused before executing — no lifecycle events.
|
|
12501
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
11412
12502
|
this.pushEvent({
|
|
11413
12503
|
type: 'tool-result',
|
|
11414
12504
|
callId: call.id,
|
|
@@ -11420,6 +12510,7 @@ export class AgentFramework {
|
|
|
11420
12510
|
}
|
|
11421
12511
|
const config = this.mcplServerConfigs.get(serverId);
|
|
11422
12512
|
if (!isToolAllowed(toolName, config)) {
|
|
12513
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
11423
12514
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
|
|
11424
12515
|
this.pushEvent({
|
|
11425
12516
|
type: 'tool-result',
|
|
@@ -11574,6 +12665,8 @@ export class AgentFramework {
|
|
|
11574
12665
|
.catch((error) => {
|
|
11575
12666
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
11576
12667
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
|
|
12668
|
+
// RFC-007: transport error, closed connection or timeout — no result.
|
|
12669
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
11577
12670
|
this.pushEvent({
|
|
11578
12671
|
type: 'tool-result',
|
|
11579
12672
|
callId: call.id,
|
|
@@ -11598,6 +12691,8 @@ export class AgentFramework {
|
|
|
11598
12691
|
const home = this.conversationAgentHomes.get(agentName);
|
|
11599
12692
|
if (home) {
|
|
11600
12693
|
const reject = (error) => {
|
|
12694
|
+
// RFC-007: the guard refuses before the tool runs — no lifecycle events.
|
|
12695
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
11601
12696
|
this.emitTrace({
|
|
11602
12697
|
type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
|
|
11603
12698
|
error: `conversation agent ${agentName}: ${error}`,
|
|
@@ -11676,6 +12771,7 @@ export class AgentFramework {
|
|
|
11676
12771
|
.catch((error) => {
|
|
11677
12772
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
11678
12773
|
this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
|
|
12774
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
11679
12775
|
this.pushEvent({
|
|
11680
12776
|
type: 'tool-result',
|
|
11681
12777
|
callId: call.id,
|
|
@@ -11894,7 +12990,8 @@ export class AgentFramework {
|
|
|
11894
12990
|
if (announce && this.channelRegistry) {
|
|
11895
12991
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
11896
12992
|
const agent = this.agents.get(agentName);
|
|
11897
|
-
|
|
12993
|
+
const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
|
|
12994
|
+
if (silentTurn || agent?.proseRouting === 'disabled') {
|
|
11898
12995
|
console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
|
|
11899
12996
|
}
|
|
11900
12997
|
else if (agent?.proseRouting === 'explicit') {
|
|
@@ -12669,6 +13766,7 @@ export class AgentFramework {
|
|
|
12669
13766
|
catch (error) {
|
|
12670
13767
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
12671
13768
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
13769
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
12672
13770
|
result = { success: false, error: err.message, isError: true };
|
|
12673
13771
|
}
|
|
12674
13772
|
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
@@ -12691,6 +13789,7 @@ export class AgentFramework {
|
|
|
12691
13789
|
.catch((error) => {
|
|
12692
13790
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
12693
13791
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
13792
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
12694
13793
|
this.pushEvent({
|
|
12695
13794
|
type: 'tool-result',
|
|
12696
13795
|
callId: call.id,
|