@animalabs/agent-framework 0.19.0 → 0.20.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 +1169 -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/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/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 +592 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +12 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +33 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -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/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 +69 -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-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/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 +1201 -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/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- 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 +730 -0
- package/src/mcpl/transport.ts +35 -1
- package/src/mcpl/types.ts +69 -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,34 @@ 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
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
5941
|
+
if (event.triggerInference) {
|
|
5942
|
+
this.pendingRequests.push({
|
|
5943
|
+
agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
|
|
5944
|
+
channelId: event.channelId,
|
|
5945
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
5946
|
+
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
5947
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5948
|
+
});
|
|
5949
|
+
}
|
|
5950
|
+
return { agent: target.name, messageId: id };
|
|
5951
|
+
}
|
|
5952
|
+
if (this.conversationRouter)
|
|
5953
|
+
return undefined; // the trunk never holds channel traffic
|
|
5954
|
+
event = { ...event, targetAgents: [target.name] };
|
|
5955
|
+
}
|
|
5956
|
+
else if (this.conversationRouter) {
|
|
5957
|
+
return this.routeConversationIncoming(event, metadata);
|
|
5762
5958
|
}
|
|
5763
5959
|
// Addressed-while-closed invitation — parity with the push-event path
|
|
5764
5960
|
// (channels.publish surfaces like portal-mcpl deliver mentions here).
|
|
@@ -5796,7 +5992,8 @@ export class AgentFramework {
|
|
|
5796
5992
|
if (divert) {
|
|
5797
5993
|
metadata.tuneOut = { epochId: divert.epochId };
|
|
5798
5994
|
}
|
|
5799
|
-
const
|
|
5995
|
+
const placement = { agent: '' };
|
|
5996
|
+
const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
5800
5997
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
5801
5998
|
if (event.triggerInference && !divert) {
|
|
5802
5999
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
@@ -5822,9 +6019,11 @@ export class AgentFramework {
|
|
|
5822
6019
|
// Addressed messages outrank ambient chatter when a batched wake
|
|
5823
6020
|
// picks the turn's frozen speech locus.
|
|
5824
6021
|
addressed,
|
|
6022
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5825
6023
|
});
|
|
5826
6024
|
}
|
|
5827
6025
|
}
|
|
6026
|
+
return placement.agent ? placement : undefined;
|
|
5828
6027
|
}
|
|
5829
6028
|
/**
|
|
5830
6029
|
* Route an incoming channel message through the ConversationRouter:
|
|
@@ -5905,8 +6104,10 @@ export class AgentFramework {
|
|
|
5905
6104
|
channelId: event.channelId,
|
|
5906
6105
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
5907
6106
|
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
6107
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
5908
6108
|
});
|
|
5909
6109
|
}
|
|
6110
|
+
return { agent: agent.name, messageId: id };
|
|
5910
6111
|
}
|
|
5911
6112
|
/**
|
|
5912
6113
|
* Spawn a persistent per-channel conversation agent ("fork") from the
|
|
@@ -5955,6 +6156,8 @@ export class AgentFramework {
|
|
|
5955
6156
|
}
|
|
5956
6157
|
const config = { ...templateConfig, name, strategy: undefined };
|
|
5957
6158
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
6159
|
+
this.restoreToolResultGuardSetting(agent);
|
|
6160
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
5958
6161
|
this.agents.set(name, agent);
|
|
5959
6162
|
this.agentConfigs.set(name, config);
|
|
5960
6163
|
this.conversationAgentHomes.set(name, channelId);
|
|
@@ -6112,6 +6315,19 @@ export class AgentFramework {
|
|
|
6112
6315
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
6113
6316
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
6114
6317
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
6318
|
+
// The label is for reading; the prefix must be ONE token that resolves back
|
|
6319
|
+
// here, on this server (a DM label "DM: alice" quoted verbatim parses as
|
|
6320
|
+
// target "#DM:"). A registry that can name targets is authoritative: no safe
|
|
6321
|
+
// token means no prefix option. A stand-in without proseTargetFor gets a
|
|
6322
|
+
// whitespace-free guess, never a label with spaces.
|
|
6323
|
+
const namer = this.channelRegistry;
|
|
6324
|
+
const target = typeof namer.proseTargetFor === 'function'
|
|
6325
|
+
? namer.proseTargetFor(opts.channelId, opts.serverId)
|
|
6326
|
+
: [label ? `#${label}` : undefined, opts.channelId].find((t) => !!t && !/\s/.test(t));
|
|
6327
|
+
const replyOption = target
|
|
6328
|
+
? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
|
|
6329
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
|
|
6330
|
+
: `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
|
|
6115
6331
|
const missedCount = opts.missedMessages ?? 0;
|
|
6116
6332
|
const missedNote = missedCount > 0
|
|
6117
6333
|
? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
|
|
@@ -6125,8 +6341,7 @@ export class AgentFramework {
|
|
|
6125
6341
|
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
6126
6342
|
missedNote +
|
|
6127
6343
|
`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` +
|
|
6344
|
+
replyOption +
|
|
6130
6345
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
6131
6346
|
(maxBackscroll > 0
|
|
6132
6347
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -6145,6 +6360,484 @@ export class AgentFramework {
|
|
|
6145
6360
|
},
|
|
6146
6361
|
};
|
|
6147
6362
|
}
|
|
6363
|
+
// ==========================================================================
|
|
6364
|
+
// RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
|
|
6365
|
+
// ==========================================================================
|
|
6366
|
+
/**
|
|
6367
|
+
* RFC-006 §3.2 binding identity: the configured server id bound to its
|
|
6368
|
+
* endpoint/command. Survives reconnects to the same peer; changes when the
|
|
6369
|
+
* id is reassigned to another endpoint (restartMcplServer with a new config).
|
|
6370
|
+
*/
|
|
6371
|
+
coalescingBinding(serverId) {
|
|
6372
|
+
const config = this.mcplServerConfigs.get(serverId);
|
|
6373
|
+
return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
|
|
6374
|
+
}
|
|
6375
|
+
initializePushCoalescer() {
|
|
6376
|
+
const coalescer = new PushCoalescer({
|
|
6377
|
+
isUnread: (p) => this.isUnreadPlacement(p),
|
|
6378
|
+
remove: (p) => this.removePlacement(p),
|
|
6379
|
+
deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
|
|
6380
|
+
wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
|
|
6381
|
+
cancelWake: (subject) => this.cancelCoalescedWakes(subject),
|
|
6382
|
+
authorized: (occ) => this.coalescedAuthorized(occ),
|
|
6383
|
+
audience: (occ) => this.coalescedAudience(occ),
|
|
6384
|
+
render: (occ, params) => {
|
|
6385
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
6386
|
+
if (!server?.isConnected)
|
|
6387
|
+
throw new Error('server disconnected');
|
|
6388
|
+
return server.sendPushRender(params);
|
|
6389
|
+
},
|
|
6390
|
+
audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
|
|
6391
|
+
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
|
|
6392
|
+
recordReceipt: (record) => {
|
|
6393
|
+
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
6394
|
+
// must not lose its receipt, the subject's birth, or (deferred) the
|
|
6395
|
+
// accepted work itself to a crash. A failure here fails the acceptance.
|
|
6396
|
+
this.coalescingRecentReceipts.push(record);
|
|
6397
|
+
this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
|
|
6398
|
+
},
|
|
6399
|
+
wasPublished: (subject, eventId) => {
|
|
6400
|
+
// Boot-time only: the occurrence's durable delivery identity
|
|
6401
|
+
// (subject + eventId in message metadata) in any agent's context.
|
|
6402
|
+
for (const agent of this.agents.values()) {
|
|
6403
|
+
try {
|
|
6404
|
+
if (agent.getContextManager().getAllMessages().some((m) => m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId))
|
|
6405
|
+
return true;
|
|
6406
|
+
}
|
|
6407
|
+
catch { /* a context that cannot be read cannot prove publication */ }
|
|
6408
|
+
}
|
|
6409
|
+
return false;
|
|
6410
|
+
},
|
|
6411
|
+
commit: () => this.commitCoalescingDurable(),
|
|
6412
|
+
saveNow: (snapshot) => {
|
|
6413
|
+
if (this.coalescingSaveTimer) {
|
|
6414
|
+
clearTimeout(this.coalescingSaveTimer);
|
|
6415
|
+
this.coalescingSaveTimer = null;
|
|
6416
|
+
}
|
|
6417
|
+
this.coalescingSnapshotDirty = null;
|
|
6418
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot);
|
|
6419
|
+
this.coalescingRecentReceipts = [];
|
|
6420
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
6421
|
+
},
|
|
6422
|
+
});
|
|
6423
|
+
try {
|
|
6424
|
+
coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID));
|
|
6425
|
+
const recent = this.store.getStateJson(COALESCING_RECENT_ID);
|
|
6426
|
+
if (Array.isArray(recent)) {
|
|
6427
|
+
coalescer.restoreReceipts(recent);
|
|
6428
|
+
// Until the next snapshot flush checkpoints the recovered state, these
|
|
6429
|
+
// records are its only durable form: keep them in the writable buffer
|
|
6430
|
+
// so the next acceptance appends to them instead of replacing them.
|
|
6431
|
+
this.coalescingRecentReceipts = recent;
|
|
6432
|
+
}
|
|
6433
|
+
}
|
|
6434
|
+
catch (err) {
|
|
6435
|
+
console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
|
|
6436
|
+
}
|
|
6437
|
+
this.pushCoalescer = coalescer;
|
|
6438
|
+
// Batches restored from a snapshot still owe the agent a wake (§14 v41).
|
|
6439
|
+
for (const occ of coalescer.pendingBatchOccurrences())
|
|
6440
|
+
void this.wakeForCoalescedBatch(occ);
|
|
6441
|
+
}
|
|
6442
|
+
/**
|
|
6443
|
+
* Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
|
|
6444
|
+
* written so far is fsynced before the caller proceeds. Group-committed —
|
|
6445
|
+
* all acceptances that arrive in one event-loop turn share one
|
|
6446
|
+
* `store.sync()` — so throughput is bounded by fsync rate per batch, not
|
|
6447
|
+
* per message.
|
|
6448
|
+
*/
|
|
6449
|
+
commitCoalescingDurable() {
|
|
6450
|
+
if (!this.coalescingCommit) {
|
|
6451
|
+
this.coalescingCommit = new Promise((resolve, reject) => {
|
|
6452
|
+
setImmediate(() => {
|
|
6453
|
+
this.coalescingCommit = null;
|
|
6454
|
+
try {
|
|
6455
|
+
this.store.sync();
|
|
6456
|
+
resolve();
|
|
6457
|
+
}
|
|
6458
|
+
catch (err) {
|
|
6459
|
+
reject(err);
|
|
6460
|
+
}
|
|
6461
|
+
});
|
|
6462
|
+
});
|
|
6463
|
+
}
|
|
6464
|
+
return this.coalescingCommit;
|
|
6465
|
+
}
|
|
6466
|
+
scheduleCoalescingSnapshot(snapshot) {
|
|
6467
|
+
this.coalescingSnapshotDirty = snapshot;
|
|
6468
|
+
if (this.coalescingSaveTimer)
|
|
6469
|
+
return;
|
|
6470
|
+
const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
|
|
6471
|
+
timer.unref?.();
|
|
6472
|
+
this.coalescingSaveTimer = timer;
|
|
6473
|
+
}
|
|
6474
|
+
flushCoalescingSnapshot() {
|
|
6475
|
+
if (this.coalescingSaveTimer) {
|
|
6476
|
+
clearTimeout(this.coalescingSaveTimer);
|
|
6477
|
+
this.coalescingSaveTimer = null;
|
|
6478
|
+
}
|
|
6479
|
+
const snapshot = this.coalescingSnapshotDirty;
|
|
6480
|
+
this.coalescingSnapshotDirty = null;
|
|
6481
|
+
if (!snapshot)
|
|
6482
|
+
return;
|
|
6483
|
+
try {
|
|
6484
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot());
|
|
6485
|
+
this.coalescingRecentReceipts = [];
|
|
6486
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
6487
|
+
}
|
|
6488
|
+
catch (err) {
|
|
6489
|
+
console.error('[coalescing] could not persist coalescing state:', err);
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
/** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
|
|
6493
|
+
admitCoalesced(work) {
|
|
6494
|
+
const run = this.coalescingAdmission.then(work, work);
|
|
6495
|
+
this.coalescingAdmission = run.then(() => undefined, () => undefined);
|
|
6496
|
+
return run;
|
|
6497
|
+
}
|
|
6498
|
+
handleCoalescedIncoming(serverId, message, event) {
|
|
6499
|
+
return this.admitCoalesced(async () => {
|
|
6500
|
+
if (!this.pushCoalescer)
|
|
6501
|
+
throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
6502
|
+
validateCoalesceMember(message.coalesce, 'channel');
|
|
6503
|
+
if (typeof message.eventId !== 'string' || !message.eventId) {
|
|
6504
|
+
throw new CoalesceError('eventId', 'eventId is required with coalesce');
|
|
6505
|
+
}
|
|
6506
|
+
if (typeof message.timestamp !== 'string')
|
|
6507
|
+
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
6508
|
+
validateCoalescedContent(message.content);
|
|
6509
|
+
const c = message.coalesce;
|
|
6510
|
+
const result = await this.pushCoalescer.accept({
|
|
6511
|
+
serverId,
|
|
6512
|
+
binding: this.coalescingBinding(serverId),
|
|
6513
|
+
scope: { kind: 'channel', id: message.channelId },
|
|
6514
|
+
key: c.key,
|
|
6515
|
+
eventId: message.eventId,
|
|
6516
|
+
timestamp: message.timestamp,
|
|
6517
|
+
retract: !!c.retract,
|
|
6518
|
+
deferred: false,
|
|
6519
|
+
initial: !!c.initial,
|
|
6520
|
+
tags: event.tags,
|
|
6521
|
+
content: message.content,
|
|
6522
|
+
identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
|
|
6523
|
+
event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
|
|
6524
|
+
});
|
|
6525
|
+
return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
|
|
6526
|
+
});
|
|
6527
|
+
}
|
|
6528
|
+
handleCoalescedPush(serverId, params, event) {
|
|
6529
|
+
return this.admitCoalesced(async () => {
|
|
6530
|
+
if (!this.pushCoalescer)
|
|
6531
|
+
throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
6532
|
+
validateCoalesceMember(params.coalesce, 'push');
|
|
6533
|
+
if (typeof params.eventId !== 'string' || !params.eventId)
|
|
6534
|
+
throw new CoalesceError('eventId', 'eventId is required');
|
|
6535
|
+
if (typeof params.timestamp !== 'string')
|
|
6536
|
+
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
6537
|
+
validateCoalescedContent(params.payload?.content);
|
|
6538
|
+
const c = params.coalesce;
|
|
6539
|
+
if (c.channelId)
|
|
6540
|
+
this.assertChannelScopedPush(serverId, c.channelId);
|
|
6541
|
+
const origin = params.origin ?? {};
|
|
6542
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
6543
|
+
return this.pushCoalescer.accept({
|
|
6544
|
+
serverId,
|
|
6545
|
+
binding: this.coalescingBinding(serverId),
|
|
6546
|
+
featureSet: params.featureSet,
|
|
6547
|
+
scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
|
|
6548
|
+
key: c.key,
|
|
6549
|
+
eventId: params.eventId,
|
|
6550
|
+
timestamp: params.timestamp,
|
|
6551
|
+
retract: !!c.retract,
|
|
6552
|
+
deferred: !!c.deferred,
|
|
6553
|
+
initial: !!c.initial,
|
|
6554
|
+
data: c.data,
|
|
6555
|
+
tags: event.tags,
|
|
6556
|
+
content: params.payload.content,
|
|
6557
|
+
...(c.channelId ? {
|
|
6558
|
+
identity: {
|
|
6559
|
+
...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
|
|
6560
|
+
...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
|
|
6561
|
+
...(str(origin.authorId) ? { author: { id: str(origin.authorId), name: str(origin.authorName) ?? str(origin.authorId) } } : {}),
|
|
6562
|
+
},
|
|
6563
|
+
} : {}),
|
|
6564
|
+
event: { lane: 'push', event },
|
|
6565
|
+
});
|
|
6566
|
+
});
|
|
6567
|
+
}
|
|
6568
|
+
/** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
|
|
6569
|
+
assertChannelScopedPush(serverId, channelId) {
|
|
6570
|
+
const server = this.mcplServerRegistry?.getServer(serverId);
|
|
6571
|
+
if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
|
|
6572
|
+
throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
|
|
6573
|
+
}
|
|
6574
|
+
if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
|
|
6575
|
+
throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
|
|
6576
|
+
}
|
|
6577
|
+
}
|
|
6578
|
+
coalescedAuthorized(occ) {
|
|
6579
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
6580
|
+
if (!server?.isConnected || !server.policyEstablished)
|
|
6581
|
+
return false;
|
|
6582
|
+
// §3.2: work admitted under another binding (the id since reassigned to a
|
|
6583
|
+
// different endpoint) never acquires the replacement peer's authority —
|
|
6584
|
+
// and its private notice data is never sent to that peer.
|
|
6585
|
+
if (occ.binding !== this.coalescingBinding(occ.serverId))
|
|
6586
|
+
return false;
|
|
6587
|
+
if (occ.event.lane === 'push') {
|
|
6588
|
+
if (!server.grant.has('pushEvents'))
|
|
6589
|
+
return false;
|
|
6590
|
+
try {
|
|
6591
|
+
this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet);
|
|
6592
|
+
}
|
|
6593
|
+
catch {
|
|
6594
|
+
return false;
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
|
|
6598
|
+
// §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
|
|
6599
|
+
if (!server.grant.has('channels.incoming'))
|
|
6600
|
+
return false;
|
|
6601
|
+
if (occ.event.lane === 'push')
|
|
6602
|
+
return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
|
|
6603
|
+
return !!this.channelRegistry?.getDescriptor(occ.scope.id);
|
|
6604
|
+
}
|
|
6605
|
+
return true;
|
|
6606
|
+
}
|
|
6607
|
+
async coalescedAudience(occ) {
|
|
6608
|
+
if (occ.deliverTo)
|
|
6609
|
+
return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
|
|
6610
|
+
const explicit = occ.event.event.targetAgents;
|
|
6611
|
+
if (explicit)
|
|
6612
|
+
return explicit.filter((n) => this.agents.has(n));
|
|
6613
|
+
if (occ.scope.kind === 'channel' && this.conversationRouter) {
|
|
6614
|
+
const router = this.conversationRouter;
|
|
6615
|
+
const bound = router.getBinding(occ.scope.id)?.agentName;
|
|
6616
|
+
if (bound)
|
|
6617
|
+
return this.agents.has(bound) ? [bound] : [];
|
|
6618
|
+
// No fork yet: the same bind decision an incoming message would get. A
|
|
6619
|
+
// batch that qualifies spawns its fork now so it has someone to wake.
|
|
6620
|
+
const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
|
|
6621
|
+
const decision = router.route({
|
|
6622
|
+
channelId: occ.scope.id,
|
|
6623
|
+
mentioned: metadata.mentioned === true,
|
|
6624
|
+
kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
|
|
6625
|
+
});
|
|
6626
|
+
if (decision.kind !== 'spawn')
|
|
6627
|
+
return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
|
|
6628
|
+
try {
|
|
6629
|
+
await this.createConversationAgent(decision.agentName, occ.scope.id);
|
|
6630
|
+
router.bind(occ.scope.id, decision.agentName, decision.generation);
|
|
6631
|
+
this.persistConversationRouterState();
|
|
6632
|
+
return [decision.agentName];
|
|
6633
|
+
}
|
|
6634
|
+
catch (err) {
|
|
6635
|
+
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
|
|
6636
|
+
return [];
|
|
6637
|
+
}
|
|
6638
|
+
}
|
|
6639
|
+
return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
6640
|
+
}
|
|
6641
|
+
/** Deliver through the lane's ordinary path; report where it landed. */
|
|
6642
|
+
async deliverCoalesced(occ, materialized, assemblingFor) {
|
|
6643
|
+
const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
6644
|
+
const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
|
|
6645
|
+
if (occ.event.lane === 'channel') {
|
|
6646
|
+
const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
|
|
6647
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
6648
|
+
await this.dispatchProcessEventToModules(event);
|
|
6649
|
+
return placement;
|
|
6650
|
+
}
|
|
6651
|
+
if (occ.scope.kind === 'channel') {
|
|
6652
|
+
// §3.2: a channel-scoped push addresses the channel's subject namespace,
|
|
6653
|
+
// so it is delivered as a message OF that channel — same routing
|
|
6654
|
+
// (conversation forks, tune-out, closed-channel invitation), same reply
|
|
6655
|
+
// locus, and the subject's stable platform identity (§3.1, vector 43).
|
|
6656
|
+
const push = occ.event.event;
|
|
6657
|
+
const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
|
|
6658
|
+
const origin = push.origin ?? {};
|
|
6659
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
6660
|
+
const event = {
|
|
6661
|
+
type: 'mcpl:channel-incoming',
|
|
6662
|
+
serverId: occ.serverId,
|
|
6663
|
+
channelId: occ.scope.id,
|
|
6664
|
+
messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
|
|
6665
|
+
threadId: identity.threadId ?? str(origin.threadId),
|
|
6666
|
+
author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
|
|
6667
|
+
content: materialized ? materialized.map(convertPushBlock) : push.content,
|
|
6668
|
+
timestamp: occ.timestamp,
|
|
6669
|
+
metadata: origin,
|
|
6670
|
+
tags: push.tags,
|
|
6671
|
+
triggerInference: assemblingFor ? false : push.triggerInference,
|
|
6672
|
+
targetAgents: push.targetAgents,
|
|
6673
|
+
coalescingSubject,
|
|
6674
|
+
eventId: occ.eventId,
|
|
6675
|
+
...narrowing,
|
|
6676
|
+
...(assemblingFor ? { assemblingFor } : {}),
|
|
6677
|
+
};
|
|
6678
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
6679
|
+
await this.dispatchProcessEventToModules(event);
|
|
6680
|
+
return placement;
|
|
6681
|
+
}
|
|
6682
|
+
const event = {
|
|
6683
|
+
...occ.event.event,
|
|
6684
|
+
eventId: occ.eventId,
|
|
6685
|
+
timestamp: occ.timestamp,
|
|
6686
|
+
...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
|
|
6687
|
+
coalescingSubject,
|
|
6688
|
+
// §5.2: the materialized occurrence is consumed by the request being
|
|
6689
|
+
// assembled; the wake that started it is not repeated.
|
|
6690
|
+
...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
|
|
6691
|
+
...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
|
|
6692
|
+
};
|
|
6693
|
+
const placement = this.handleMcplPushEvent(event);
|
|
6694
|
+
await this.dispatchProcessEventToModules(event);
|
|
6695
|
+
return placement;
|
|
6696
|
+
}
|
|
6697
|
+
async wakeForCoalescedBatch(occ) {
|
|
6698
|
+
const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
6699
|
+
this.cancelCoalescedWakes(subject);
|
|
6700
|
+
if (occ.event.lane !== 'push' || !occ.event.event.triggerInference)
|
|
6701
|
+
return;
|
|
6702
|
+
const origin = occ.event.event.origin;
|
|
6703
|
+
// The wake carries the batch's channel so the turn freezes its reply
|
|
6704
|
+
// locus there (the materialized delivery at assembly wakes nobody).
|
|
6705
|
+
const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
|
|
6706
|
+
const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
|
|
6707
|
+
for (const agentName of await this.coalescedAudience(occ)) {
|
|
6708
|
+
this.pendingRequests.push({
|
|
6709
|
+
agentName,
|
|
6710
|
+
reason: 'mcpl:push-event',
|
|
6711
|
+
source: occ.serverId,
|
|
6712
|
+
timestamp: Date.now(),
|
|
6713
|
+
channelId,
|
|
6714
|
+
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
6715
|
+
addressed: isAddressedMessage(occ.tags, origin),
|
|
6716
|
+
coalescingSubject: subject,
|
|
6717
|
+
});
|
|
6718
|
+
}
|
|
6719
|
+
}
|
|
6720
|
+
/** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
|
|
6721
|
+
cancelCoalescedWakes(subject) {
|
|
6722
|
+
this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
|
|
6723
|
+
for (const cooldown of this.providerAccelerationCooldowns.values()) {
|
|
6724
|
+
cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
|
|
6725
|
+
}
|
|
6726
|
+
}
|
|
6727
|
+
/**
|
|
6728
|
+
* A placement in the deferred queue is upgraded in place once the flush has
|
|
6729
|
+
* stored it: the stored copy carries `metadata.deferredWriteId`, found by
|
|
6730
|
+
* scanning the unread tail (above the watermark) — never below it.
|
|
6731
|
+
*/
|
|
6732
|
+
resolvePlacement(p) {
|
|
6733
|
+
if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId))
|
|
6734
|
+
return;
|
|
6735
|
+
const agent = this.agents.get(p.agent);
|
|
6736
|
+
if (!agent)
|
|
6737
|
+
return;
|
|
6738
|
+
const cm = agent.getContextManager();
|
|
6739
|
+
const watermark = agent.getConsumedWatermark();
|
|
6740
|
+
const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
|
|
6741
|
+
const all = cm.getAllMessages();
|
|
6742
|
+
for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
|
|
6743
|
+
if (all[i].metadata?.deferredWriteId === p.deferredId) {
|
|
6744
|
+
p.messageId = all[i].id;
|
|
6745
|
+
delete p.deferredId;
|
|
6746
|
+
return;
|
|
6747
|
+
}
|
|
6748
|
+
}
|
|
6749
|
+
}
|
|
6750
|
+
isUnreadPlacement(p) {
|
|
6751
|
+
this.resolvePlacement(p);
|
|
6752
|
+
if (p.deferredId)
|
|
6753
|
+
return this.deferredMessages.some((m) => m.id === p.deferredId);
|
|
6754
|
+
if (!p.messageId)
|
|
6755
|
+
return false;
|
|
6756
|
+
const agent = this.agents.get(p.agent);
|
|
6757
|
+
return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
|
|
6758
|
+
}
|
|
6759
|
+
/**
|
|
6760
|
+
* Unread = stored, above the agent's consumed watermark on the current
|
|
6761
|
+
* branch, not a body-group shard (immutable), and not folded into any
|
|
6762
|
+
* summary (compression is consumption, §3.3). Conservative on every doubt.
|
|
6763
|
+
*/
|
|
6764
|
+
isUnreadStoredMessage(agent, messageId) {
|
|
6765
|
+
const cm = agent.getContextManager();
|
|
6766
|
+
let message;
|
|
6767
|
+
try {
|
|
6768
|
+
message = cm.getMessage(messageId);
|
|
6769
|
+
}
|
|
6770
|
+
catch {
|
|
6771
|
+
return false;
|
|
6772
|
+
}
|
|
6773
|
+
if (!message || message.bodyGroupId)
|
|
6774
|
+
return false;
|
|
6775
|
+
// Every agent that can read this message must not have compiled past it.
|
|
6776
|
+
// Residents share one message slot, so a message one of them read is
|
|
6777
|
+
// history for all of them (the never-rewrite rule is about the message,
|
|
6778
|
+
// not the agent that happened to be asked). A tune-out-diverted message
|
|
6779
|
+
// never enters a resident's compiled view; only the subconscious reads
|
|
6780
|
+
// it through its merged view — no subconscious, nobody reads it.
|
|
6781
|
+
let readers;
|
|
6782
|
+
if (message.metadata?.tuneOut) {
|
|
6783
|
+
const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
|
|
6784
|
+
if (!subconscious)
|
|
6785
|
+
return true;
|
|
6786
|
+
readers = [subconscious];
|
|
6787
|
+
}
|
|
6788
|
+
else {
|
|
6789
|
+
readers = this.sharedSlotAgents.has(agent)
|
|
6790
|
+
? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
|
|
6791
|
+
: [agent];
|
|
6792
|
+
}
|
|
6793
|
+
const branch = cm.currentBranch().name;
|
|
6794
|
+
const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
|
|
6795
|
+
for (const reader of readers) {
|
|
6796
|
+
const watermark = reader.getConsumedWatermark();
|
|
6797
|
+
if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence)
|
|
6798
|
+
return false;
|
|
6799
|
+
// Compression is consumption (§3.3): folded into any reader's summary.
|
|
6800
|
+
try {
|
|
6801
|
+
const rcm = reader.getContextManager();
|
|
6802
|
+
const strategy = rcm.getStrategy();
|
|
6803
|
+
for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
|
|
6804
|
+
const entry = strategy.getSummary?.(summary.id);
|
|
6805
|
+
if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId))
|
|
6806
|
+
return false;
|
|
6807
|
+
}
|
|
6808
|
+
}
|
|
6809
|
+
catch {
|
|
6810
|
+
return false;
|
|
6811
|
+
}
|
|
6812
|
+
}
|
|
6813
|
+
return true;
|
|
6814
|
+
}
|
|
6815
|
+
removePlacement(p) {
|
|
6816
|
+
this.resolvePlacement(p);
|
|
6817
|
+
if (p.deferredId) {
|
|
6818
|
+
const before = this.deferredMessages.length;
|
|
6819
|
+
this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
|
|
6820
|
+
if (this.deferredMessages.length === before)
|
|
6821
|
+
return false;
|
|
6822
|
+
if (this.quiesced || this.deferredWritesPersisted)
|
|
6823
|
+
this.persistDeferredWrites();
|
|
6824
|
+
return true;
|
|
6825
|
+
}
|
|
6826
|
+
if (!p.messageId)
|
|
6827
|
+
return false;
|
|
6828
|
+
const agent = this.agents.get(p.agent);
|
|
6829
|
+
if (!agent)
|
|
6830
|
+
return false;
|
|
6831
|
+
try {
|
|
6832
|
+
agent.getContextManager().removeMessage(p.messageId);
|
|
6833
|
+
this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
|
|
6834
|
+
return true;
|
|
6835
|
+
}
|
|
6836
|
+
catch (err) {
|
|
6837
|
+
console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
|
|
6838
|
+
return false;
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6148
6841
|
/**
|
|
6149
6842
|
* Convert an MCPL push event to a context message.
|
|
6150
6843
|
*/
|
|
@@ -6160,6 +6853,7 @@ export class AgentFramework {
|
|
|
6160
6853
|
eventId: event.eventId,
|
|
6161
6854
|
triggered: event.triggerInference ?? false,
|
|
6162
6855
|
...(event.tags ? { tags: event.tags } : {}),
|
|
6856
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6163
6857
|
};
|
|
6164
6858
|
// `origin.channelId` is the server-internal raw id (a bare Discord
|
|
6165
6859
|
// snowflake) — unroutable as a locus and unresolvable by the agent. Store
|
|
@@ -6189,8 +6883,23 @@ export class AgentFramework {
|
|
|
6189
6883
|
Object.assign(metadata, invitation.metadataPatch);
|
|
6190
6884
|
}
|
|
6191
6885
|
}
|
|
6192
|
-
|
|
6193
|
-
|
|
6886
|
+
// Silent heartbeat ticks are control-plane wakes, not autobiography.
|
|
6887
|
+
// Accept the no-message path only for the heartbeat feature's own exact
|
|
6888
|
+
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
6889
|
+
// content merely by setting `origin.silent`.
|
|
6890
|
+
const silentHeartbeat = event.serverId === 'heartbeat' &&
|
|
6891
|
+
event.featureSet === 'heartbeat' &&
|
|
6892
|
+
event.origin?.source === 'heartbeat' &&
|
|
6893
|
+
event.origin?.silent === true &&
|
|
6894
|
+
content.length === 0;
|
|
6895
|
+
const placement = { agent: '' };
|
|
6896
|
+
if (!silentHeartbeat) {
|
|
6897
|
+
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
6898
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
6899
|
+
}
|
|
6900
|
+
else {
|
|
6901
|
+
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
|
|
6902
|
+
}
|
|
6194
6903
|
if (event.triggerInference) {
|
|
6195
6904
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
6196
6905
|
const targetAgents = event.targetAgents
|
|
@@ -6206,9 +6915,16 @@ export class AgentFramework {
|
|
|
6206
6915
|
// they must outrank ambient chatter in a batched wake's locus
|
|
6207
6916
|
// selection just like their channels/incoming counterparts.
|
|
6208
6917
|
addressed: isAddressedMessage(event.tags, event.origin),
|
|
6918
|
+
...(silentHeartbeat ? {
|
|
6919
|
+
suppressProse: true,
|
|
6920
|
+
ephemeralSystemPrompt: '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
|
|
6921
|
+
'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
|
|
6922
|
+
} : {}),
|
|
6923
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6209
6924
|
});
|
|
6210
6925
|
}
|
|
6211
6926
|
}
|
|
6927
|
+
return placement.agent ? placement : undefined;
|
|
6212
6928
|
}
|
|
6213
6929
|
/**
|
|
6214
6930
|
* Derive the MCPL composite channel id (the outbound routing locus) for a
|
|
@@ -6353,7 +7069,9 @@ export class AgentFramework {
|
|
|
6353
7069
|
// route the resumed turn into the wrong channel.
|
|
6354
7070
|
const newestByKey = new Map();
|
|
6355
7071
|
for (const request of parked) {
|
|
6356
|
-
|
|
7072
|
+
// A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
|
|
7073
|
+
// must not absorb an unrelated wake that would vanish with it.
|
|
7074
|
+
const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
|
|
6357
7075
|
const prev = newestByKey.get(key);
|
|
6358
7076
|
if (!prev || request.timestamp >= prev.timestamp) {
|
|
6359
7077
|
newestByKey.set(key, request);
|
|
@@ -6479,8 +7197,13 @@ export class AgentFramework {
|
|
|
6479
7197
|
provReq = r;
|
|
6480
7198
|
}
|
|
6481
7199
|
}
|
|
7200
|
+
// A silent tick never suppresses a genuine coalesced wake. If any
|
|
7201
|
+
// ordinary request shares this batch, the ordinary turn semantics win.
|
|
7202
|
+
const silentOnly = requests.every((r) => r.suppressProse === true);
|
|
6482
7203
|
await this.startAgentStream(agent, {
|
|
6483
7204
|
...trigger,
|
|
7205
|
+
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
|
|
7206
|
+
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
|
|
6484
7207
|
channelId: channelReq?.channelId,
|
|
6485
7208
|
addressed: addressedReq !== undefined,
|
|
6486
7209
|
// A context-budget restart continues the same logical turn: it keeps
|
|
@@ -7008,6 +7731,9 @@ export class AgentFramework {
|
|
|
7008
7731
|
* driveStream (whose finally then owns clearing it).
|
|
7009
7732
|
*/
|
|
7010
7733
|
async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
|
|
7734
|
+
const continuingTurn = trigger?.reason === 'context_budget_restart'
|
|
7735
|
+
|| trigger?.reason === 'tool_result_guard_retry';
|
|
7736
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
7011
7737
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
7012
7738
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
7013
7739
|
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
@@ -7023,7 +7749,7 @@ export class AgentFramework {
|
|
|
7023
7749
|
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
7024
7750
|
// products — undoing the turn must not destroy them.
|
|
7025
7751
|
if (attempt === 0 &&
|
|
7026
|
-
|
|
7752
|
+
!continuingTurn &&
|
|
7027
7753
|
this.deferredMessages.length > 0) {
|
|
7028
7754
|
{
|
|
7029
7755
|
const deferred = this.drainDeferredFor(agent.name);
|
|
@@ -7043,8 +7769,20 @@ export class AgentFramework {
|
|
|
7043
7769
|
this.ackDeferredWrites();
|
|
7044
7770
|
}
|
|
7045
7771
|
}
|
|
7772
|
+
// RFC-006 §5: deferred batches for this agent render now, at the assembly
|
|
7773
|
+
// boundary — after the deferred flush (same window: turn alive, compile
|
|
7774
|
+
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
7775
|
+
// Not on a context-budget restart: that continues the same logical turn.
|
|
7776
|
+
if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
|
|
7777
|
+
try {
|
|
7778
|
+
await this.pushCoalescer.assemble(agent.name);
|
|
7779
|
+
}
|
|
7780
|
+
catch (err) {
|
|
7781
|
+
console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
|
|
7782
|
+
}
|
|
7783
|
+
}
|
|
7046
7784
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
7047
|
-
if (attempt === 0) {
|
|
7785
|
+
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
7048
7786
|
this.recordTurnCheckpoint(agent.name);
|
|
7049
7787
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
7050
7788
|
}
|
|
@@ -7061,7 +7799,7 @@ export class AgentFramework {
|
|
|
7061
7799
|
// after the restart fell through to the hours-old defaultPublishChannel.
|
|
7062
7800
|
// Keep the turn's trigger channel across restarts; only real new turns
|
|
7063
7801
|
// reset it.
|
|
7064
|
-
if (
|
|
7802
|
+
if (!continuingTurn) {
|
|
7065
7803
|
if (trigger?.channelId) {
|
|
7066
7804
|
this.activeTriggerChannels.set(agent.name, trigger.channelId);
|
|
7067
7805
|
}
|
|
@@ -7081,11 +7819,11 @@ export class AgentFramework {
|
|
|
7081
7819
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
7082
7820
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
7083
7821
|
// KV stability).
|
|
7084
|
-
if (
|
|
7822
|
+
if (continuingTurn) {
|
|
7085
7823
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
7086
7824
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
7087
7825
|
}
|
|
7088
|
-
if (
|
|
7826
|
+
if (!continuingTurn) {
|
|
7089
7827
|
if (attempt === 0)
|
|
7090
7828
|
this.maybePrimeProseMode(agent);
|
|
7091
7829
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
@@ -7104,9 +7842,9 @@ export class AgentFramework {
|
|
|
7104
7842
|
this.turnProseDeliveries.delete(agent.name);
|
|
7105
7843
|
this.turnProseSuppressed.delete(agent.name);
|
|
7106
7844
|
this.proseHybridSuppressed.delete(agent.name);
|
|
7107
|
-
if (
|
|
7845
|
+
if (turnProseRouting === 'hybrid')
|
|
7108
7846
|
this.proseTargetPins.delete(agent.name);
|
|
7109
|
-
if (
|
|
7847
|
+
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
7110
7848
|
// Explicit and disabled prose routing have no inferred locus.
|
|
7111
7849
|
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
7112
7850
|
// prose target at all. Neither mode freezes or announces a locus.
|
|
@@ -7126,6 +7864,14 @@ export class AgentFramework {
|
|
|
7126
7864
|
this.announceLocusIfChanged(agent.name, locus);
|
|
7127
7865
|
}
|
|
7128
7866
|
}
|
|
7867
|
+
// A context-budget restart normally preserves the turn locus, but a
|
|
7868
|
+
// silent control-plane wake must remain locusless even if an explicit tool
|
|
7869
|
+
// opened/pinned a channel mid-turn.
|
|
7870
|
+
if (trigger?.suppressProse) {
|
|
7871
|
+
this.turnLocusPins.delete(agent.name);
|
|
7872
|
+
this.proseTargetPins.delete(agent.name);
|
|
7873
|
+
this.proseContinuations.delete(agent.name);
|
|
7874
|
+
}
|
|
7129
7875
|
this.touchEphemeralRun(agent.name, true);
|
|
7130
7876
|
this.emitTrace({
|
|
7131
7877
|
type: 'inference:started',
|
|
@@ -7133,10 +7879,11 @@ export class AgentFramework {
|
|
|
7133
7879
|
// The turn-frozen locus was pinned just above (kept across a
|
|
7134
7880
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
7135
7881
|
channelId: this.turnLocusPins.get(agent.name),
|
|
7882
|
+
...(trigger?.suppressProse ? { silent: true } : {}),
|
|
7136
7883
|
});
|
|
7137
7884
|
// A budget restart continues the same logical inference window. The
|
|
7138
7885
|
// predecessor keeps EventGate liveness until its successor terminates.
|
|
7139
|
-
if (
|
|
7886
|
+
if (!continuingTurn) {
|
|
7140
7887
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
7141
7888
|
}
|
|
7142
7889
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
@@ -7149,19 +7896,14 @@ export class AgentFramework {
|
|
|
7149
7896
|
// Same channel choice as driveStream's own startTyping (which stays, is
|
|
7150
7897
|
// idempotent per channel, and owns the 7s refresh); the catch below stops
|
|
7151
7898
|
// it on the no-driveStream failure paths (e.g. a compile refusal).
|
|
7152
|
-
const earlyTypingChannel =
|
|
7899
|
+
const earlyTypingChannel = turnProseRouting === 'explicit'
|
|
7153
7900
|
? trigger?.channelId ?? null
|
|
7154
7901
|
: this.turnLocusPins.get(agent.name) ?? null;
|
|
7155
7902
|
if (earlyTypingChannel)
|
|
7156
7903
|
this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
7157
7904
|
try {
|
|
7158
7905
|
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);
|
|
7906
|
+
const tools = this.agentToolSurface(agent, requestSnapshot);
|
|
7165
7907
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
7166
7908
|
// Both produce ContextInjection[] that get merged before inference.
|
|
7167
7909
|
let injections;
|
|
@@ -7192,6 +7934,14 @@ export class AgentFramework {
|
|
|
7192
7934
|
console.error('beforeInference hook error:', error);
|
|
7193
7935
|
}
|
|
7194
7936
|
}
|
|
7937
|
+
if (trigger?.ephemeralSystemPrompt) {
|
|
7938
|
+
const silentInjection = {
|
|
7939
|
+
namespace: 'framework:silent-heartbeat',
|
|
7940
|
+
position: 'system',
|
|
7941
|
+
content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
|
|
7942
|
+
};
|
|
7943
|
+
injections = injections ? [...injections, silentInjection] : [silentInjection];
|
|
7944
|
+
}
|
|
7195
7945
|
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
7196
7946
|
// Do not create a provider stream after its generation lost ownership.
|
|
7197
7947
|
if (this.agents.get(agent.name) !== agent) {
|
|
@@ -7291,6 +8041,7 @@ export class AgentFramework {
|
|
|
7291
8041
|
}
|
|
7292
8042
|
}
|
|
7293
8043
|
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
|
|
8044
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
7294
8045
|
const startTime = Date.now();
|
|
7295
8046
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
7296
8047
|
const myStreamId = agent.streamId;
|
|
@@ -7344,7 +8095,10 @@ export class AgentFramework {
|
|
|
7344
8095
|
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
7345
8096
|
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
7346
8097
|
// clears it, because the following prose is a reply to a new message.
|
|
7347
|
-
let turnSilenced =
|
|
8098
|
+
let turnSilenced = trigger?.suppressProse === true
|
|
8099
|
+
|| (trigger?.reason === 'tool_result_guard_retry'
|
|
8100
|
+
&& this.guardRetryTurnSilenced.get(agent.name) === true);
|
|
8101
|
+
this.guardRetryTurnSilenced.delete(agent.name);
|
|
7348
8102
|
// Live routing is only trusted when the membrane provides verbatim
|
|
7349
8103
|
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
7350
8104
|
// The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
|
|
@@ -7364,9 +8118,9 @@ export class AgentFramework {
|
|
|
7364
8118
|
// this doesn't violate never-guess: it says "attending to what you
|
|
7365
8119
|
// sent here", which is true regardless of where the reply goes.
|
|
7366
8120
|
// Heartbeat/no-trigger explicit turns show no indicator.
|
|
7367
|
-
const typingChannel =
|
|
8121
|
+
const typingChannel = turnProseRouting === 'disabled'
|
|
7368
8122
|
? null
|
|
7369
|
-
:
|
|
8123
|
+
: turnProseRouting === 'explicit'
|
|
7370
8124
|
? trigger?.channelId ?? null
|
|
7371
8125
|
: resolveTurnLocus();
|
|
7372
8126
|
if (typingChannel)
|
|
@@ -7383,6 +8137,9 @@ export class AgentFramework {
|
|
|
7383
8137
|
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
7384
8138
|
// finalized) instead of being appended to — see the 'retrying' case.
|
|
7385
8139
|
let outgoingInferenceId = newOutgoingInferenceId();
|
|
8140
|
+
// RFC-007: every inference id THIS stream mints, so the stream's end
|
|
8141
|
+
// aborts only its own open tool calls — never a successor's.
|
|
8142
|
+
const lifecycleInferenceIds = new Set([outgoingInferenceId]);
|
|
7386
8143
|
let outgoingIndex = 0;
|
|
7387
8144
|
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
7388
8145
|
// finally below (the one block every exit path shares). Which terminal
|
|
@@ -7402,10 +8159,10 @@ export class AgentFramework {
|
|
|
7402
8159
|
// turns until completion: otherwise speculative outgoing chunks could
|
|
7403
8160
|
// expose the wrapper before the fail-closed classifier runs.
|
|
7404
8161
|
const proseStream = this.channelRegistry &&
|
|
7405
|
-
|
|
8162
|
+
turnProseRouting !== 'disabled' &&
|
|
7406
8163
|
!agent.toolWrapperProseGuard
|
|
7407
8164
|
? new ProseStreamRouter({
|
|
7408
|
-
mode:
|
|
8165
|
+
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
7409
8166
|
initialTarget: typingChannel,
|
|
7410
8167
|
resolve: (spec) => {
|
|
7411
8168
|
const r = this.channelRegistry.resolveProseTarget(spec);
|
|
@@ -7418,6 +8175,23 @@ export class AgentFramework {
|
|
|
7418
8175
|
this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
|
|
7419
8176
|
}
|
|
7420
8177
|
};
|
|
8178
|
+
// Tool-result-guard publication boundary. While a SUBMITTED batch is
|
|
8179
|
+
// pending (or a guard recovery is running), a refusal can still abandon
|
|
8180
|
+
// this physical round, so its streamed text must not reach any surface
|
|
8181
|
+
// yet — neither the prose router nor the inference:tokens trace (TTS and
|
|
8182
|
+
// other trace consumers voice it). Hold both; release in order on a clean
|
|
8183
|
+
// round (tool-calls / non-guard completion), discard on a guard refusal.
|
|
8184
|
+
let guardHeld = [];
|
|
8185
|
+
const releaseGuardHeld = () => {
|
|
8186
|
+
const held = guardHeld;
|
|
8187
|
+
guardHeld = [];
|
|
8188
|
+
for (const item of held) {
|
|
8189
|
+
this.emitTrace(item.trace);
|
|
8190
|
+
if (proseStream && item.text !== undefined)
|
|
8191
|
+
emitOutgoing(proseStream.feed(item.text));
|
|
8192
|
+
}
|
|
8193
|
+
};
|
|
8194
|
+
const discardGuardHeld = () => { guardHeld = []; };
|
|
7421
8195
|
const adoptInjectedRound = () => {
|
|
7422
8196
|
if (!this.midTurnInputSignals.has(agent.name))
|
|
7423
8197
|
return;
|
|
@@ -7425,7 +8199,7 @@ export class AgentFramework {
|
|
|
7425
8199
|
// A new conversational message, not merely a tool result, means any
|
|
7426
8200
|
// earlier explicit delivery has completed its conversational job.
|
|
7427
8201
|
// Routing is NOT touched: the turn locus stays frozen.
|
|
7428
|
-
turnSilenced =
|
|
8202
|
+
turnSilenced = trigger?.suppressProse === true;
|
|
7429
8203
|
};
|
|
7430
8204
|
try {
|
|
7431
8205
|
for await (const event of stream) {
|
|
@@ -7439,8 +8213,8 @@ export class AgentFramework {
|
|
|
7439
8213
|
}
|
|
7440
8214
|
this.touchEphemeralRun(agent.name, true);
|
|
7441
8215
|
switch (event.type) {
|
|
7442
|
-
case 'tokens':
|
|
7443
|
-
|
|
8216
|
+
case 'tokens': {
|
|
8217
|
+
const trace = {
|
|
7444
8218
|
type: 'inference:tokens',
|
|
7445
8219
|
agentName: agent.name,
|
|
7446
8220
|
content: event.content,
|
|
@@ -7450,11 +8224,18 @@ export class AgentFramework {
|
|
|
7450
8224
|
// lets trace consumers tag every chunk with the channel this
|
|
7451
8225
|
// turn's prose is bound for, without re-deriving routing.
|
|
7452
8226
|
channelId: typingChannel ?? undefined,
|
|
7453
|
-
}
|
|
7454
|
-
|
|
7455
|
-
|
|
8227
|
+
};
|
|
8228
|
+
const text = event.meta.type === 'text' ? event.content : undefined;
|
|
8229
|
+
if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
|
|
8230
|
+
guardHeld.push({ trace, text });
|
|
8231
|
+
}
|
|
8232
|
+
else {
|
|
8233
|
+
this.emitTrace(trace);
|
|
8234
|
+
if (proseStream && text !== undefined)
|
|
8235
|
+
emitOutgoing(proseStream.feed(text));
|
|
7456
8236
|
}
|
|
7457
8237
|
break;
|
|
8238
|
+
}
|
|
7458
8239
|
case 'retrying': {
|
|
7459
8240
|
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
7460
8241
|
// RetryingEvent contract we must DISCARD everything this call has
|
|
@@ -7472,7 +8253,9 @@ export class AgentFramework {
|
|
|
7472
8253
|
(event.category ? ` category=${event.category}` : '') +
|
|
7473
8254
|
' — discarding the refused attempt');
|
|
7474
8255
|
outgoingInferenceId = newOutgoingInferenceId();
|
|
8256
|
+
lifecycleInferenceIds.add(outgoingInferenceId);
|
|
7475
8257
|
outgoingIndex = 0;
|
|
8258
|
+
discardGuardHeld();
|
|
7476
8259
|
proseStream?.reset();
|
|
7477
8260
|
this.emitTrace({
|
|
7478
8261
|
type: 'inference:tokens',
|
|
@@ -7497,6 +8280,10 @@ export class AgentFramework {
|
|
|
7497
8280
|
break;
|
|
7498
8281
|
}
|
|
7499
8282
|
case 'tool-calls': {
|
|
8283
|
+
// A tool-call response is a clean physical round: admit its
|
|
8284
|
+
// preceding results before storing/dispatching this new round.
|
|
8285
|
+
agent.toolResultGuard.accept();
|
|
8286
|
+
releaseGuardHeld();
|
|
7500
8287
|
adoptInjectedRound();
|
|
7501
8288
|
hadToolCalls = true;
|
|
7502
8289
|
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
@@ -7562,7 +8349,14 @@ export class AgentFramework {
|
|
|
7562
8349
|
// So all calls here have valid input — {} is legitimate for parameterless tools.
|
|
7563
8350
|
agent.enterWaitingForTools(event.calls, stream);
|
|
7564
8351
|
for (const call of event.calls) {
|
|
8352
|
+
// RFC-007: register at the one dispatch point for model-issued
|
|
8353
|
+
// calls, keyed by (agent, call id) with this inference's id;
|
|
8354
|
+
// `started` goes out once dispatch returns. Refusal sites inside
|
|
8355
|
+
// dispatch run synchronously and call refuse() first, so a call
|
|
8356
|
+
// the host refuses produces no events.
|
|
8357
|
+
this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
|
|
7565
8358
|
this.dispatchToolCall(agent.name, call);
|
|
8359
|
+
this.toolLifecycleEmitter?.open(agent.name, call.id);
|
|
7566
8360
|
}
|
|
7567
8361
|
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
7568
8362
|
// instead of batching every segment to the end of the turn. Long
|
|
@@ -7586,11 +8380,11 @@ export class AgentFramework {
|
|
|
7586
8380
|
liveProseRouting = true;
|
|
7587
8381
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
7588
8382
|
if (roundSegments.length > 0) {
|
|
7589
|
-
if (
|
|
8383
|
+
if (turnProseRouting === 'disabled') {
|
|
7590
8384
|
console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
|
|
7591
8385
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
7592
8386
|
}
|
|
7593
|
-
else if (
|
|
8387
|
+
else if (turnProseRouting === 'hybrid') {
|
|
7594
8388
|
if (turnSilenced) {
|
|
7595
8389
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
7596
8390
|
}
|
|
@@ -7603,7 +8397,7 @@ export class AgentFramework {
|
|
|
7603
8397
|
}
|
|
7604
8398
|
}
|
|
7605
8399
|
}
|
|
7606
|
-
else if (
|
|
8400
|
+
else if (turnProseRouting === 'explicit') {
|
|
7607
8401
|
// Explicit mode: every segment through the prose gateway.
|
|
7608
8402
|
// Silencing does not apply — unprefixed prose bounces and
|
|
7609
8403
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -7641,7 +8435,87 @@ export class AgentFramework {
|
|
|
7641
8435
|
case 'complete': {
|
|
7642
8436
|
adoptInjectedRound();
|
|
7643
8437
|
const durationMs = Date.now() - startTime;
|
|
7644
|
-
|
|
8438
|
+
let response = event.response;
|
|
8439
|
+
const guardCategory = response.raw?.response?.stop_details?.category ?? 'unknown';
|
|
8440
|
+
// A staged batch the strategy omitted from the request cannot
|
|
8441
|
+
// have caused this refusal: settle it and fall through to the
|
|
8442
|
+
// ordinary refusal handling (rewind/reaction) below.
|
|
8443
|
+
if (response.stopReason === 'refusal')
|
|
8444
|
+
agent.toolResultGuard.settleUnsubmitted(guardCategory);
|
|
8445
|
+
const guardRefusal = response.stopReason === 'refusal'
|
|
8446
|
+
&& (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
|
|
8447
|
+
if (guardRefusal) {
|
|
8448
|
+
discardGuardHeld();
|
|
8449
|
+
const category = guardCategory;
|
|
8450
|
+
const withheld = agent.toolResultGuard.withhold(category);
|
|
8451
|
+
// Nothing from the abandoned physical attempt is published or
|
|
8452
|
+
// persisted as assistant speech, including a terminal refusal
|
|
8453
|
+
// after the single recovery retry.
|
|
8454
|
+
proseStream?.reset();
|
|
8455
|
+
if (withheld) {
|
|
8456
|
+
const usage = response.details?.usage ?? response.usage;
|
|
8457
|
+
const tokenUsage = usage ? {
|
|
8458
|
+
input: usage.inputTokens, output: usage.outputTokens,
|
|
8459
|
+
cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
|
|
8460
|
+
} : undefined;
|
|
8461
|
+
this.noteRefusal(agent.name, category, tokenUsage);
|
|
8462
|
+
// The abandoned stream was billed (membrane usage here is
|
|
8463
|
+
// cumulative across this physical tool loop): count it before
|
|
8464
|
+
// the retry opens a fresh stream with its own usage.
|
|
8465
|
+
const abandonedUsage = response.details?.usage;
|
|
8466
|
+
if (abandonedUsage) {
|
|
8467
|
+
this.usageTracker.onInferenceCompleted(agent.name, {
|
|
8468
|
+
inputTokens: abandonedUsage.inputTokens,
|
|
8469
|
+
outputTokens: abandonedUsage.outputTokens,
|
|
8470
|
+
cacheCreationTokens: abandonedUsage.cacheCreationTokens,
|
|
8471
|
+
cacheReadTokens: abandonedUsage.cacheReadTokens,
|
|
8472
|
+
}, abandonedUsage.estimatedCost
|
|
8473
|
+
? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
|
|
8474
|
+
: undefined);
|
|
8475
|
+
this.persistUsageState();
|
|
8476
|
+
}
|
|
8477
|
+
this.logInference({
|
|
8478
|
+
timestamp: startTime, agentName: agent.name, requestId,
|
|
8479
|
+
success: false, error: 'Tool output withheld by the guard',
|
|
8480
|
+
request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
|
|
8481
|
+
});
|
|
8482
|
+
console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
|
|
8483
|
+
this.emitTrace({
|
|
8484
|
+
type: 'inference:stream_restarted', agentName: agent.name,
|
|
8485
|
+
reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
|
|
8486
|
+
budget: agent.maxStreamTokens,
|
|
8487
|
+
});
|
|
8488
|
+
await turnSpeechChain;
|
|
8489
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
8490
|
+
generationLost = true;
|
|
8491
|
+
lifecyclePhase = 'aborted';
|
|
8492
|
+
return;
|
|
8493
|
+
}
|
|
8494
|
+
agent.reset();
|
|
8495
|
+
preserveEventGateForSuccessor = true;
|
|
8496
|
+
lifecyclePhase = 'aborted';
|
|
8497
|
+
// Carry same-turn explicit-send suppression into the retry:
|
|
8498
|
+
// a text-only recovery after a successful send must not post
|
|
8499
|
+
// a postscript to a message already delivered.
|
|
8500
|
+
if (turnSilenced)
|
|
8501
|
+
this.guardRetryTurnSilenced.set(agent.name, true);
|
|
8502
|
+
// Restart inference inside this logical turn. No tool is
|
|
8503
|
+
// executed again and no settle/checkpoint/locus reset occurs.
|
|
8504
|
+
await this.startAgentStream(agent, {
|
|
8505
|
+
...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
|
|
8506
|
+
source: trigger?.source ?? 'framework', timestamp: Date.now(),
|
|
8507
|
+
}, attempt);
|
|
8508
|
+
return;
|
|
8509
|
+
}
|
|
8510
|
+
const lastResult = response.content.reduce((last, block, index) => block.type === 'tool_result' ? index : last, -1);
|
|
8511
|
+
response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
|
|
8512
|
+
agent.toolResultGuard.recovering = false;
|
|
8513
|
+
}
|
|
8514
|
+
else if (response.stopReason !== 'refusal') {
|
|
8515
|
+
agent.toolResultGuard.accept();
|
|
8516
|
+
}
|
|
8517
|
+
if (!guardRefusal)
|
|
8518
|
+
releaseGuardHeld();
|
|
7645
8519
|
// If the agent is still waiting_for_tools when 'complete' fires
|
|
7646
8520
|
// (shouldn't happen after incomplete-tool-call fix, but guard anyway),
|
|
7647
8521
|
// flush pending blocks + inject error results for any remaining pending tools.
|
|
@@ -7667,8 +8541,12 @@ export class AgentFramework {
|
|
|
7667
8541
|
// one door a giant blob still walks through).
|
|
7668
8542
|
const readyState = agent.state;
|
|
7669
8543
|
if (readyState.status === 'ready') {
|
|
7670
|
-
const
|
|
7671
|
-
|
|
8544
|
+
const cap = this.resolveToolResultInlineCap(agent).cap;
|
|
8545
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
|
|
8546
|
+
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
|
|
8547
|
+
// The response is already complete: this batch is never
|
|
8548
|
+
// submitted in this turn, so it cannot be refused. Admit it.
|
|
8549
|
+
agent.toolResultGuard.settleTurnEnded();
|
|
7672
8550
|
}
|
|
7673
8551
|
}
|
|
7674
8552
|
// Add assistant response to context.
|
|
@@ -7855,7 +8733,7 @@ export class AgentFramework {
|
|
|
7855
8733
|
`(remaining ${forced.remaining})`);
|
|
7856
8734
|
}, () => this.finishUnstick(agent.name, false, category));
|
|
7857
8735
|
}
|
|
7858
|
-
else if (rh?.autoRewind) {
|
|
8736
|
+
else if (rh?.autoRewind && !guardRefusal) {
|
|
7859
8737
|
const cap = Math.max(1, rh.maxRewinds ?? 3);
|
|
7860
8738
|
const used = this.refusalRewinds.get(agent.name) ?? 0;
|
|
7861
8739
|
doRewind(used < cap, (count) => {
|
|
@@ -7894,7 +8772,7 @@ export class AgentFramework {
|
|
|
7894
8772
|
this.refusalStreak.delete(agent.name);
|
|
7895
8773
|
}
|
|
7896
8774
|
// Dispatch speech (and thoughts if any)
|
|
7897
|
-
if (speechContent.length > 0 || thoughts.length > 0) {
|
|
8775
|
+
if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
|
|
7898
8776
|
const speechContext = {
|
|
7899
8777
|
turnComplete: true,
|
|
7900
8778
|
trigger: trigger ?? {
|
|
@@ -7920,11 +8798,19 @@ export class AgentFramework {
|
|
|
7920
8798
|
.join('\n')
|
|
7921
8799
|
.trim();
|
|
7922
8800
|
if (speechText) {
|
|
7923
|
-
if (
|
|
8801
|
+
if (turnProseRouting === 'disabled') {
|
|
7924
8802
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
7925
8803
|
this.recordProseSuppression(agent.name, 1);
|
|
7926
8804
|
}
|
|
7927
|
-
else if (
|
|
8805
|
+
else if (turnSilenced && turnProseRouting !== 'explicit') {
|
|
8806
|
+
// Only reachable on a guard recovery that carried the
|
|
8807
|
+
// same-turn send suppression, or a suppressProse trigger
|
|
8808
|
+
// (a fresh text-only turn starts unsilenced). Explicit
|
|
8809
|
+
// mode is exempt, as mid-turn.
|
|
8810
|
+
console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
|
|
8811
|
+
this.recordProseSuppression(agent.name, 1);
|
|
8812
|
+
}
|
|
8813
|
+
else if (turnProseRouting === 'hybrid') {
|
|
7928
8814
|
const locus = resolveTurnLocus();
|
|
7929
8815
|
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
7930
8816
|
try {
|
|
@@ -7934,7 +8820,7 @@ export class AgentFramework {
|
|
|
7934
8820
|
console.error('text-only hybrid prose delivery failed:', err);
|
|
7935
8821
|
}
|
|
7936
8822
|
}
|
|
7937
|
-
else if (
|
|
8823
|
+
else if (turnProseRouting === 'explicit') {
|
|
7938
8824
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
7939
8825
|
try {
|
|
7940
8826
|
await this.deliverProse(agent, speechText);
|
|
@@ -7989,13 +8875,13 @@ export class AgentFramework {
|
|
|
7989
8875
|
// land before the trailing prose. Awaited even when silenced —
|
|
7990
8876
|
// the chain may still be flushing earlier rounds' posts.
|
|
7991
8877
|
await turnSpeechChain;
|
|
7992
|
-
if (
|
|
8878
|
+
if (turnProseRouting === 'disabled') {
|
|
7993
8879
|
if (segments.length > 0) {
|
|
7994
8880
|
console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
|
|
7995
8881
|
this.recordProseSuppression(agent.name, segments.length);
|
|
7996
8882
|
}
|
|
7997
8883
|
}
|
|
7998
|
-
else if (
|
|
8884
|
+
else if (turnProseRouting === 'hybrid') {
|
|
7999
8885
|
if (silenced && segments.length > 0) {
|
|
8000
8886
|
this.recordProseSuppression(agent.name, segments.length);
|
|
8001
8887
|
}
|
|
@@ -8011,7 +8897,7 @@ export class AgentFramework {
|
|
|
8011
8897
|
}
|
|
8012
8898
|
}
|
|
8013
8899
|
}
|
|
8014
|
-
else if (
|
|
8900
|
+
else if (turnProseRouting === 'explicit') {
|
|
8015
8901
|
if (segments.length > 0) {
|
|
8016
8902
|
console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
|
|
8017
8903
|
for (const seg of segments) {
|
|
@@ -8061,7 +8947,7 @@ export class AgentFramework {
|
|
|
8061
8947
|
// awaited before trailing dispatch, and trailing/text-only
|
|
8062
8948
|
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
8063
8949
|
// envelopes acknowledge themselves through the prose gateway.
|
|
8064
|
-
if (
|
|
8950
|
+
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
8065
8951
|
this.appendProseDeliveryReceipt(agent);
|
|
8066
8952
|
}
|
|
8067
8953
|
// Explicit-prose `!` continuation: a prose segment this turn asked
|
|
@@ -8225,7 +9111,7 @@ export class AgentFramework {
|
|
|
8225
9111
|
// deliveries map persists (cleared only at fresh turn
|
|
8226
9112
|
// start) and the continuation stream's end writes ONE
|
|
8227
9113
|
// receipt for the whole turn.
|
|
8228
|
-
if (cancelKind === 'turn_ended' &&
|
|
9114
|
+
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
8229
9115
|
await turnSpeechChain;
|
|
8230
9116
|
this.appendProseDeliveryReceipt(agent);
|
|
8231
9117
|
}
|
|
@@ -8430,6 +9316,13 @@ export class AgentFramework {
|
|
|
8430
9316
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
8431
9317
|
// crash emits nothing — that is the documented best-effort limit, and
|
|
8432
9318
|
// why consumers keep their safety timeout.
|
|
9319
|
+
//
|
|
9320
|
+
// RFC-007 §7.3: a tool call still open when its turn's stream ends was
|
|
9321
|
+
// cancelled before a result — its terminal is `aborted`. (A normal turn
|
|
9322
|
+
// has none: the stream waits for every result before continuing.)
|
|
9323
|
+
// Scoped to this stream's own inference ids: a budget-restart successor
|
|
9324
|
+
// may already be running for the same agent.
|
|
9325
|
+
this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
|
|
8433
9326
|
this.hookOrchestrator?.emitLifecycle({
|
|
8434
9327
|
inferenceId: outgoingInferenceId,
|
|
8435
9328
|
conversationId: agent.name,
|
|
@@ -8444,6 +9337,23 @@ export class AgentFramework {
|
|
|
8444
9337
|
// (typing still stops, compression still runs — matching the observed
|
|
8445
9338
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
8446
9339
|
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
9340
|
+
// A frame that ends without handing off to a successor (abort,
|
|
9341
|
+
// exhausted errors) must not leave its batch pending: a later turn
|
|
9342
|
+
// would resubmit the originals and claim its own refusal. Successor
|
|
9343
|
+
// frames (error-policy retry, budget/guard restart) bump streamId or
|
|
9344
|
+
// set preserveEventGateForSuccessor and keep the batch.
|
|
9345
|
+
if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
|
|
9346
|
+
// abandon() clears the batch before its audit append, so a failed
|
|
9347
|
+
// write cannot re-arm it; it must not abort the rest of teardown
|
|
9348
|
+
// (gate release, turn token, provider admission) either.
|
|
9349
|
+
try {
|
|
9350
|
+
agent.toolResultGuard.abandon('aborted');
|
|
9351
|
+
}
|
|
9352
|
+
catch (error) {
|
|
9353
|
+
console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
|
|
9354
|
+
}
|
|
9355
|
+
}
|
|
9356
|
+
agent.toolResultGuard.recovering = false;
|
|
8447
9357
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
8448
9358
|
}
|
|
8449
9359
|
if (!generationLost && ownsPhysicalStream) {
|
|
@@ -8756,6 +9666,7 @@ export class AgentFramework {
|
|
|
8756
9666
|
const startTime = Date.now();
|
|
8757
9667
|
this.runCodeExecution(agentName, call)
|
|
8758
9668
|
.catch((error) => {
|
|
9669
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
8759
9670
|
// runCodeExecution is designed not to reject; this is the last-resort
|
|
8760
9671
|
// guard so a bug here can never strand the agent in waiting_for_tools.
|
|
8761
9672
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -8845,21 +9756,41 @@ export class AgentFramework {
|
|
|
8845
9756
|
isError: true,
|
|
8846
9757
|
};
|
|
8847
9758
|
}
|
|
9759
|
+
// A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
|
|
9760
|
+
if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
|
|
9761
|
+
return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
|
|
9762
|
+
}
|
|
9763
|
+
const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
|
|
9764
|
+
const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
|
|
9765
|
+
const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
|
|
9766
|
+
const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
|
|
9767
|
+
const capNote = requestedMs !== undefined && requestedMs > ceilingMs
|
|
9768
|
+
? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
|
|
9769
|
+
: undefined;
|
|
8848
9770
|
const agent = this.agents.get(agentName);
|
|
8849
9771
|
const surface = agent
|
|
8850
9772
|
? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
|
|
8851
9773
|
: this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
|
|
8852
9774
|
const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
|
|
8853
9775
|
if (input.background === true) {
|
|
8854
|
-
|
|
9776
|
+
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
|
|
9777
|
+
if (capNote && started.success && started.data && typeof started.data === 'object') {
|
|
9778
|
+
started.data.time_limit_note = capNote;
|
|
9779
|
+
}
|
|
9780
|
+
return started;
|
|
8855
9781
|
}
|
|
8856
9782
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
8857
9783
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
8858
|
-
const exec = await runner.exec(input.code, injected);
|
|
9784
|
+
const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
|
|
8859
9785
|
const endTurn = this.scriptDeferredEndTurn.delete(agentName);
|
|
8860
9786
|
return {
|
|
8861
9787
|
success: true,
|
|
8862
|
-
data: {
|
|
9788
|
+
data: {
|
|
9789
|
+
stdout: exec.stdout,
|
|
9790
|
+
stderr: exec.stderr,
|
|
9791
|
+
return_code: exec.returnCode,
|
|
9792
|
+
...(capNote ? { time_limit_note: capNote } : {}),
|
|
9793
|
+
},
|
|
8863
9794
|
isError: false,
|
|
8864
9795
|
...(endTurn ? { endTurn: true } : {}),
|
|
8865
9796
|
};
|
|
@@ -8875,7 +9806,7 @@ export class AgentFramework {
|
|
|
8875
9806
|
* limitation is stated in the tool description so agents can calibrate
|
|
8876
9807
|
* how much to rely on an armed wake.
|
|
8877
9808
|
*/
|
|
8878
|
-
startBackgroundScript(agentName, code, injected) {
|
|
9809
|
+
startBackgroundScript(agentName, code, injected, timeLimitMs) {
|
|
8879
9810
|
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
8880
9811
|
// would outlive its owner and its wake would land in the primary
|
|
8881
9812
|
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
@@ -8899,7 +9830,8 @@ export class AgentFramework {
|
|
|
8899
9830
|
};
|
|
8900
9831
|
}
|
|
8901
9832
|
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
8902
|
-
|
|
9833
|
+
// The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
|
|
9834
|
+
const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
8903
9835
|
// Journal: a file under the agent's first read-write workspace mount so
|
|
8904
9836
|
// their existing read/grep/shell tools work on it. Python appends
|
|
8905
9837
|
// directly to the materialized path; watch-mode mounts sync it back to
|
|
@@ -8960,6 +9892,7 @@ export class AgentFramework {
|
|
|
8960
9892
|
script_id: scriptId,
|
|
8961
9893
|
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
8962
9894
|
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
9895
|
+
lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
|
|
8963
9896
|
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
8964
9897
|
},
|
|
8965
9898
|
};
|
|
@@ -9532,17 +10465,96 @@ export class AgentFramework {
|
|
|
9532
10465
|
// Append one record per event — same rationale as logInference.
|
|
9533
10466
|
this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
|
|
9534
10467
|
}
|
|
10468
|
+
/** Forget one server's RFC-008 class declarations (see the close handler). */
|
|
10469
|
+
dropMcplToolClasses(serverId) {
|
|
10470
|
+
// Tolerate partially-constructed frameworks (tests build them with
|
|
10471
|
+
// Object.create): nothing cached means nothing to forget.
|
|
10472
|
+
if (!this.mcplToolClasses?.size)
|
|
10473
|
+
return;
|
|
10474
|
+
const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
|
|
10475
|
+
for (const name of [...this.mcplToolClasses.keys()]) {
|
|
10476
|
+
if (name.startsWith(`${prefix}--`))
|
|
10477
|
+
this.mcplToolClasses.delete(name);
|
|
10478
|
+
}
|
|
10479
|
+
}
|
|
9535
10480
|
/**
|
|
9536
|
-
*
|
|
9537
|
-
*
|
|
10481
|
+
* RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
|
|
10482
|
+
* its effective class, and for MCPL tools the providing connection and
|
|
10483
|
+
* the server's own tool name. Host knowledge (built-in table, embedding
|
|
10484
|
+
* host's table) applies only to tools the host implements; an MCPL tool's
|
|
10485
|
+
* class comes from an operator override or its server's declaration.
|
|
10486
|
+
*/
|
|
10487
|
+
describeToolForLifecycle(tool) {
|
|
10488
|
+
const mcpl = this.resolveMcplTool(tool);
|
|
10489
|
+
const { classes } = this.effectiveToolClass(tool, mcpl !== null);
|
|
10490
|
+
if (mcpl)
|
|
10491
|
+
return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
|
|
10492
|
+
return { class: classes };
|
|
10493
|
+
}
|
|
10494
|
+
/**
|
|
10495
|
+
* RFC-008 §5.1: a tool's effective class and the source that decided it.
|
|
10496
|
+
* Operator overrides first; then host knowledge, for tools the host
|
|
10497
|
+
* implements (never for MCPL tools); then an MCPL tool's own declaration.
|
|
10498
|
+
* Tolerates partially-constructed frameworks (tests use Object.create).
|
|
10499
|
+
*/
|
|
10500
|
+
effectiveToolClass(tool, isMcpl) {
|
|
10501
|
+
return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
|
|
10502
|
+
overrides: this.toolClassOverrides ?? [],
|
|
10503
|
+
host: isMcpl ? [] : (this.hostToolClasses ?? []),
|
|
10504
|
+
});
|
|
10505
|
+
}
|
|
10506
|
+
/**
|
|
10507
|
+
* RFC-008 §6: tools with their effective class and where that class came
|
|
10508
|
+
* from (`override`, `host`, `server`, or `none` for an unclassed tool,
|
|
10509
|
+
* which policy treats as most restrictive). For operators: a surprising
|
|
10510
|
+
* class should be visible before it matters.
|
|
10511
|
+
*
|
|
10512
|
+
* With `agentName`, exactly the tools that agent is shown. Without, every
|
|
10513
|
+
* tool the framework offers to anyone: the shared board plus each agent's
|
|
10514
|
+
* own (the subconscious's surface, `prose_help`), each listed once.
|
|
10515
|
+
*/
|
|
10516
|
+
listToolClasses(agentName) {
|
|
10517
|
+
let tools;
|
|
10518
|
+
if (agentName !== undefined) {
|
|
10519
|
+
const agent = this.agents.get(agentName);
|
|
10520
|
+
if (!agent)
|
|
10521
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
10522
|
+
tools = this.agentToolSurface(agent);
|
|
10523
|
+
}
|
|
10524
|
+
else {
|
|
10525
|
+
tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
|
|
10526
|
+
}
|
|
10527
|
+
const seen = new Set();
|
|
10528
|
+
const listed = [];
|
|
10529
|
+
for (const t of tools) {
|
|
10530
|
+
if (seen.has(t.name))
|
|
10531
|
+
continue;
|
|
10532
|
+
seen.add(t.name);
|
|
10533
|
+
const mcpl = this.resolveMcplTool(t.name);
|
|
10534
|
+
const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
|
|
10535
|
+
listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
|
|
10536
|
+
}
|
|
10537
|
+
return listed;
|
|
10538
|
+
}
|
|
10539
|
+
/**
|
|
10540
|
+
* Find the MCPL server for a tool: [serverId, prefix], or null.
|
|
10541
|
+
* Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
|
|
10542
|
+
* both. The server whose tools/list produced the name decides; a name no
|
|
10543
|
+
* live server listed (stale, or invented by the model) goes to the longest
|
|
10544
|
+
* matching prefix. Never prefix-map insertion order.
|
|
9538
10545
|
*/
|
|
9539
10546
|
resolveMcplTool(toolName) {
|
|
9540
|
-
|
|
9541
|
-
|
|
10547
|
+
const listedBy = this.mcplToolServers?.get(toolName);
|
|
10548
|
+
let best = null;
|
|
10549
|
+
for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
|
|
10550
|
+
if (!toolName.startsWith(prefix + '--'))
|
|
10551
|
+
continue;
|
|
10552
|
+
if (serverId === listedBy)
|
|
9542
10553
|
return [serverId, prefix];
|
|
9543
|
-
|
|
10554
|
+
if (!best || prefix.length > best[1].length)
|
|
10555
|
+
best = [serverId, prefix];
|
|
9544
10556
|
}
|
|
9545
|
-
return
|
|
10557
|
+
return best;
|
|
9546
10558
|
}
|
|
9547
10559
|
dispatchToolCall(agentName, call) {
|
|
9548
10560
|
// Enrich call with caller identity so modules can resolve the calling agent
|
|
@@ -9712,6 +10724,8 @@ export class AgentFramework {
|
|
|
9712
10724
|
})
|
|
9713
10725
|
.catch((error) => {
|
|
9714
10726
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
10727
|
+
// RFC-007: the module threw — the host could not obtain a result.
|
|
10728
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
9715
10729
|
this.emitTrace({
|
|
9716
10730
|
type: 'tool:failed',
|
|
9717
10731
|
module: moduleName,
|
|
@@ -9777,6 +10791,7 @@ export class AgentFramework {
|
|
|
9777
10791
|
? this.pendingAssistantBlocks.has(agent.name)
|
|
9778
10792
|
: this.pendingAssistantBlocks.size > 0;
|
|
9779
10793
|
if (!hasToolResult &&
|
|
10794
|
+
opts?.bypassDeferralFor !== agent.name &&
|
|
9780
10795
|
(this.quiesced ||
|
|
9781
10796
|
midToolCycle ||
|
|
9782
10797
|
this.activeTurnTokens.has(agent.name) ||
|
|
@@ -9793,9 +10808,18 @@ export class AgentFramework {
|
|
|
9793
10808
|
this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
|
|
9794
10809
|
if (this.quiesced || this.deferredWritesPersisted)
|
|
9795
10810
|
this.persistDeferredWrites();
|
|
10811
|
+
if (opts?.placement) {
|
|
10812
|
+
opts.placement.agent = agent.name;
|
|
10813
|
+
opts.placement.deferredId = id;
|
|
10814
|
+
}
|
|
9796
10815
|
return ''; // Deferred — flushed at the target's next boundary
|
|
9797
10816
|
}
|
|
9798
|
-
|
|
10817
|
+
const stored = agent.getContextManager().addMessage(participant, content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata);
|
|
10818
|
+
if (opts?.placement) {
|
|
10819
|
+
opts.placement.agent = agent.name;
|
|
10820
|
+
opts.placement.messageId = stored;
|
|
10821
|
+
}
|
|
10822
|
+
return stored;
|
|
9799
10823
|
}
|
|
9800
10824
|
/**
|
|
9801
10825
|
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
@@ -10239,6 +11263,11 @@ export class AgentFramework {
|
|
|
10239
11263
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
10240
11264
|
this.featureSetManager = new FeatureSetManager();
|
|
10241
11265
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
11266
|
+
this.toolLifecycleEmitter = new ToolLifecycleEmitter({
|
|
11267
|
+
observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
|
|
11268
|
+
configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
|
|
11269
|
+
describe: (tool) => this.describeToolForLifecycle(tool),
|
|
11270
|
+
});
|
|
10242
11271
|
// Build prefix map and store configs for tool routing
|
|
10243
11272
|
for (const config of serverConfigs) {
|
|
10244
11273
|
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
|
|
@@ -10250,7 +11279,8 @@ export class AgentFramework {
|
|
|
10250
11279
|
const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
|
|
10251
11280
|
?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
|
|
10252
11281
|
// Push events handler (Step 6)
|
|
10253
|
-
this.
|
|
11282
|
+
this.initializePushCoalescer();
|
|
11283
|
+
this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event));
|
|
10254
11284
|
// Server-initiated inference router (Step 6)
|
|
10255
11285
|
this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
|
|
10256
11286
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
@@ -10261,6 +11291,7 @@ export class AgentFramework {
|
|
|
10261
11291
|
// Channel registry (Step 7)
|
|
10262
11292
|
this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
|
|
10263
11293
|
store: this.store,
|
|
11294
|
+
handleCoalescedIncoming: (serverId, message, event) => this.handleCoalescedIncoming(serverId, message, event),
|
|
10264
11295
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
10265
11296
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
10266
11297
|
if (server) {
|
|
@@ -10329,11 +11360,17 @@ export class AgentFramework {
|
|
|
10329
11360
|
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
10330
11361
|
this.mcplHostCapabilities = {
|
|
10331
11362
|
version: '0.5',
|
|
11363
|
+
// RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
|
|
11364
|
+
// channel-scoped pushes, one-hour retry window.
|
|
11365
|
+
eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
|
|
10332
11366
|
pushEvents: true,
|
|
10333
11367
|
contextHooks: {
|
|
10334
11368
|
beforeInference: true,
|
|
10335
11369
|
},
|
|
10336
11370
|
inferenceLifecycle: true,
|
|
11371
|
+
// RFC-007: tools/lifecycle (metadata, and requested argument fields
|
|
11372
|
+
// under their own grant) plus the tools/observe filter.
|
|
11373
|
+
toolLifecycle: { observe: true, inputs: true },
|
|
10337
11374
|
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
10338
11375
|
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
10339
11376
|
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
@@ -11066,17 +12103,28 @@ export class AgentFramework {
|
|
|
11066
12103
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
11067
12104
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
11068
12105
|
const connected = connection?.isConnected ?? false;
|
|
12106
|
+
// Attribute through the dispatch resolver: a bare prefix match would
|
|
12107
|
+
// also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
|
|
12108
|
+
const ownTools = (this.mcplTools ?? []).flatMap((t) => {
|
|
12109
|
+
const hit = this.resolveMcplTool(t.name);
|
|
12110
|
+
return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
|
|
12111
|
+
});
|
|
11069
12112
|
result.push({
|
|
11070
12113
|
id,
|
|
11071
12114
|
connected,
|
|
11072
12115
|
retrying: !connected && (connection?.willReconnect ?? false),
|
|
11073
12116
|
toolPrefix: prefix,
|
|
11074
|
-
toolCount:
|
|
12117
|
+
toolCount: ownTools.length,
|
|
11075
12118
|
policyEstablished: connection?.policyEstablished ?? false,
|
|
11076
12119
|
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
11077
12120
|
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
11078
12121
|
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
11079
12122
|
allowHostCommands: config.allowHostCommands === true,
|
|
12123
|
+
toolObserveFilter: connection?.toolObserveFilter ?? null,
|
|
12124
|
+
toolClasses: ownTools.map(({ tool, serverTool }) => {
|
|
12125
|
+
const { classes, source } = this.effectiveToolClass(tool, true);
|
|
12126
|
+
return { tool, serverTool, class: classes, source };
|
|
12127
|
+
}),
|
|
11080
12128
|
manifestState: connection
|
|
11081
12129
|
? { ...connection.manifestState }
|
|
11082
12130
|
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
@@ -11109,10 +12157,15 @@ export class AgentFramework {
|
|
|
11109
12157
|
const barrier = this.discordAwarenessBarrier;
|
|
11110
12158
|
if (barrier)
|
|
11111
12159
|
await barrier.promise;
|
|
11112
|
-
this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
12160
|
+
await this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
11113
12161
|
});
|
|
11114
12162
|
// Handle server-initiated inference requests (Step 6)
|
|
11115
12163
|
connection.on('inference-request', async (params, responder) => {
|
|
12164
|
+
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
|
|
12165
|
+
if (this.pushCoalescer?.isRendering(connection.id)) {
|
|
12166
|
+
responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
|
|
12167
|
+
return;
|
|
12168
|
+
}
|
|
11116
12169
|
if (this.inferenceRouter && responder) {
|
|
11117
12170
|
const barrier = this.discordAwarenessBarrier;
|
|
11118
12171
|
if (barrier)
|
|
@@ -11150,6 +12203,21 @@ export class AgentFramework {
|
|
|
11150
12203
|
channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
|
|
11151
12204
|
});
|
|
11152
12205
|
});
|
|
12206
|
+
// RFC-007 §6: the server's filter over tools/lifecycle. Admission already
|
|
12207
|
+
// required toolLifecycle.observe (-32002 otherwise). Validation failures
|
|
12208
|
+
// leave the previous filter in force (§6.6).
|
|
12209
|
+
connection.on('tools-observe', (params, responder) => {
|
|
12210
|
+
const parsed = parseToolObserveParams(params);
|
|
12211
|
+
if (!parsed.ok) {
|
|
12212
|
+
if (responder?.respondError)
|
|
12213
|
+
responder.respondError(-32602, parsed.message, parsed.data);
|
|
12214
|
+
else
|
|
12215
|
+
console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
|
|
12216
|
+
return;
|
|
12217
|
+
}
|
|
12218
|
+
connection.toolObserveFilter = parsed.rules;
|
|
12219
|
+
responder?.respond({});
|
|
12220
|
+
});
|
|
11153
12221
|
connection.on('model-info', (_params, responder) => {
|
|
11154
12222
|
// §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
|
|
11155
12223
|
// in the DIRECT result shape, and numeric contextWindow cannot express
|
|
@@ -11164,7 +12232,7 @@ export class AgentFramework {
|
|
|
11164
12232
|
const barrier = this.discordAwarenessBarrier;
|
|
11165
12233
|
if (barrier)
|
|
11166
12234
|
await barrier.promise;
|
|
11167
|
-
this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
12235
|
+
await this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
11168
12236
|
});
|
|
11169
12237
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
11170
12238
|
connection.on('host-command', async (params, responder) => {
|
|
@@ -11300,6 +12368,10 @@ export class AgentFramework {
|
|
|
11300
12368
|
// disconnectMcplServer, which deletes the trees explicitly.
|
|
11301
12369
|
connection.on('close', (code, signal) => {
|
|
11302
12370
|
this.featureSetManager?.removeServer(connection.id);
|
|
12371
|
+
// RFC-008: a closed provider's class hints are stale — on reconnect it
|
|
12372
|
+
// may class its tools differently. Until the next tools/list its tools
|
|
12373
|
+
// are unclassed, the restrictive answer (RFC-007 §4.3).
|
|
12374
|
+
this.dropMcplToolClasses(connection.id);
|
|
11303
12375
|
this.emitTrace({
|
|
11304
12376
|
type: 'mcpl:server-closed',
|
|
11305
12377
|
serverId: connection.id,
|
|
@@ -11319,6 +12391,8 @@ export class AgentFramework {
|
|
|
11319
12391
|
return;
|
|
11320
12392
|
const tools = [];
|
|
11321
12393
|
const toolFeatureSets = new Map();
|
|
12394
|
+
const toolClasses = new Map();
|
|
12395
|
+
const toolServers = new Map();
|
|
11322
12396
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
11323
12397
|
const config = this.mcplServerConfigs.get(server.id);
|
|
11324
12398
|
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
@@ -11328,7 +12402,13 @@ export class AgentFramework {
|
|
|
11328
12402
|
if (!isToolAllowed(tool.name, config))
|
|
11329
12403
|
continue;
|
|
11330
12404
|
const namespacedName = `${prefix}--${tool.name}`;
|
|
12405
|
+
toolServers.set(namespacedName, server.id);
|
|
11331
12406
|
const attributedTool = tool;
|
|
12407
|
+
// RFC-008: the server's class hint for this tool. Never shown to
|
|
12408
|
+
// the model (the definition below is unchanged by it).
|
|
12409
|
+
const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
|
|
12410
|
+
if (declaredClasses)
|
|
12411
|
+
toolClasses.set(namespacedName, declaredClasses);
|
|
11332
12412
|
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
11333
12413
|
? attributedTool.featureSet
|
|
11334
12414
|
: typeof attributedTool._meta?.featureSet === 'string'
|
|
@@ -11352,6 +12432,8 @@ export class AgentFramework {
|
|
|
11352
12432
|
}
|
|
11353
12433
|
this.mcplTools = tools;
|
|
11354
12434
|
this.mcplToolFeatureSets = toolFeatureSets;
|
|
12435
|
+
this.mcplToolClasses = toolClasses;
|
|
12436
|
+
this.mcplToolServers = toolServers;
|
|
11355
12437
|
}
|
|
11356
12438
|
/**
|
|
11357
12439
|
* Handle a tools/list_changed notification with collapse logic.
|
|
@@ -11409,6 +12491,8 @@ export class AgentFramework {
|
|
|
11409
12491
|
const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--"
|
|
11410
12492
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
11411
12493
|
if (!server) {
|
|
12494
|
+
// RFC-007: refused before executing — no lifecycle events.
|
|
12495
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
11412
12496
|
this.pushEvent({
|
|
11413
12497
|
type: 'tool-result',
|
|
11414
12498
|
callId: call.id,
|
|
@@ -11420,6 +12504,7 @@ export class AgentFramework {
|
|
|
11420
12504
|
}
|
|
11421
12505
|
const config = this.mcplServerConfigs.get(serverId);
|
|
11422
12506
|
if (!isToolAllowed(toolName, config)) {
|
|
12507
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
11423
12508
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
|
|
11424
12509
|
this.pushEvent({
|
|
11425
12510
|
type: 'tool-result',
|
|
@@ -11574,6 +12659,8 @@ export class AgentFramework {
|
|
|
11574
12659
|
.catch((error) => {
|
|
11575
12660
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
11576
12661
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
|
|
12662
|
+
// RFC-007: transport error, closed connection or timeout — no result.
|
|
12663
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
11577
12664
|
this.pushEvent({
|
|
11578
12665
|
type: 'tool-result',
|
|
11579
12666
|
callId: call.id,
|
|
@@ -11598,6 +12685,8 @@ export class AgentFramework {
|
|
|
11598
12685
|
const home = this.conversationAgentHomes.get(agentName);
|
|
11599
12686
|
if (home) {
|
|
11600
12687
|
const reject = (error) => {
|
|
12688
|
+
// RFC-007: the guard refuses before the tool runs — no lifecycle events.
|
|
12689
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
11601
12690
|
this.emitTrace({
|
|
11602
12691
|
type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
|
|
11603
12692
|
error: `conversation agent ${agentName}: ${error}`,
|
|
@@ -11676,6 +12765,7 @@ export class AgentFramework {
|
|
|
11676
12765
|
.catch((error) => {
|
|
11677
12766
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
11678
12767
|
this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
|
|
12768
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
11679
12769
|
this.pushEvent({
|
|
11680
12770
|
type: 'tool-result',
|
|
11681
12771
|
callId: call.id,
|
|
@@ -11894,7 +12984,8 @@ export class AgentFramework {
|
|
|
11894
12984
|
if (announce && this.channelRegistry) {
|
|
11895
12985
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
11896
12986
|
const agent = this.agents.get(agentName);
|
|
11897
|
-
|
|
12987
|
+
const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
|
|
12988
|
+
if (silentTurn || agent?.proseRouting === 'disabled') {
|
|
11898
12989
|
console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
|
|
11899
12990
|
}
|
|
11900
12991
|
else if (agent?.proseRouting === 'explicit') {
|
|
@@ -12669,6 +13760,7 @@ export class AgentFramework {
|
|
|
12669
13760
|
catch (error) {
|
|
12670
13761
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
12671
13762
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
13763
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
12672
13764
|
result = { success: false, error: err.message, isError: true };
|
|
12673
13765
|
}
|
|
12674
13766
|
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
@@ -12691,6 +13783,7 @@ export class AgentFramework {
|
|
|
12691
13783
|
.catch((error) => {
|
|
12692
13784
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
12693
13785
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
13786
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
12694
13787
|
this.pushEvent({
|
|
12695
13788
|
type: 'tool-result',
|
|
12696
13789
|
callId: call.id,
|