@animalabs/agent-framework 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +153 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1175 -76
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +59 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +106 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +8 -6
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +12 -6
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +9 -4
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +607 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +15 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +65 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +68 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/conversation-router.test.js +33 -19
- package/dist/test/conversation-router.test.js.map +1 -1
- package/dist/test/conversation-routing.test.js +89 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +158 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fields.test.js +82 -0
- package/dist/test/mcpl-reference-fields.test.js.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
- package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
- package/dist/test/tools-observe-parser-conformance.test.js +129 -0
- package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1207 -80
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +115 -7
- package/src/mcpl/conversation-router.ts +12 -8
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/references.ts +11 -3
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +745 -0
- package/src/mcpl/transport.ts +67 -1
- package/src/mcpl/types.ts +71 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
package/src/framework.ts
CHANGED
|
@@ -67,7 +67,15 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
|
67
67
|
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
68
68
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
69
69
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
70
|
-
import {
|
|
70
|
+
import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
|
|
71
|
+
import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
|
|
72
|
+
import { PushHandler, convertBlock as convertPushBlock, type McplPushEvent } from './mcpl/push-handler.js';
|
|
73
|
+
import {
|
|
74
|
+
PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
|
|
75
|
+
coalescingSubjectKey, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
|
|
76
|
+
type CoalescingReceiptRecord,
|
|
77
|
+
} from './mcpl/push-coalescer.js';
|
|
78
|
+
import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js';
|
|
71
79
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
72
80
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
73
81
|
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
@@ -90,11 +98,13 @@ import {
|
|
|
90
98
|
splitPreservingImageSlots,
|
|
91
99
|
type ParsedImagePlaceholder,
|
|
92
100
|
} from './tool-image-ledger.js';
|
|
93
|
-
import { randomUUID } from 'node:crypto';
|
|
94
|
-
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
101
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
102
|
+
import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
|
|
95
103
|
import {
|
|
96
104
|
buildCodeExecutionToolDefinition,
|
|
97
105
|
CODE_EXECUTION_TOOL_NAME,
|
|
106
|
+
scriptTimeLimits,
|
|
107
|
+
validateCodeExecutionConfig,
|
|
98
108
|
} from './code-execution/tool-definition.js';
|
|
99
109
|
import { splitProseSegments } from './prose-segments.js';
|
|
100
110
|
import { cumulativeDelta } from './usage-accounting.js';
|
|
@@ -409,6 +419,35 @@ import {
|
|
|
409
419
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
410
420
|
/** Snapshot slot mirroring context writes deferred while quiesced (#122). */
|
|
411
421
|
const DEFERRED_WRITES_ID = 'framework/deferred-writes';
|
|
422
|
+
/** The event a coalesced occurrence delivers through — one per lane. */
|
|
423
|
+
interface CoalescedChannelEvent {
|
|
424
|
+
type: 'mcpl:channel-incoming';
|
|
425
|
+
serverId: string;
|
|
426
|
+
channelId: string;
|
|
427
|
+
messageId: string;
|
|
428
|
+
threadId?: string;
|
|
429
|
+
author: { id: string; name: string };
|
|
430
|
+
content: ContentBlock[];
|
|
431
|
+
timestamp: string;
|
|
432
|
+
metadata?: Record<string, unknown>;
|
|
433
|
+
tags?: string[];
|
|
434
|
+
triggerInference?: boolean;
|
|
435
|
+
targetAgents?: string[];
|
|
436
|
+
coalescingSubject?: string;
|
|
437
|
+
/** Occurrence id (RFC-006 §3.1); channel messages carry it only when coalesced. */
|
|
438
|
+
eventId?: string;
|
|
439
|
+
/** RFC-006 assembly: materialized for this agent's turn (store directly). */
|
|
440
|
+
assemblingFor?: string;
|
|
441
|
+
/** RFC-006 §3.2: deliver into this agent only (replacement / notice audience). */
|
|
442
|
+
deliverTo?: string;
|
|
443
|
+
}
|
|
444
|
+
type CoalescedDelivery =
|
|
445
|
+
| { lane: 'channel'; event: CoalescedChannelEvent }
|
|
446
|
+
| { lane: 'push'; event: McplPushEvent };
|
|
447
|
+
/** RFC-006 coalescing bookkeeping: receipts (retry window) + subject history. */
|
|
448
|
+
const COALESCING_STATE_ID = 'mcpl/coalescing';
|
|
449
|
+
/** Receipts written since the last snapshot flush (bridges the throttle window). */
|
|
450
|
+
const COALESCING_RECENT_ID = 'mcpl/coalescing-recent';
|
|
412
451
|
/** Above this serialized size the deferred-write mirror is skipped (kept in
|
|
413
452
|
* memory only) rather than rewriting a multi-MB snapshot per deferral. */
|
|
414
453
|
const DEFERRED_WRITES_PERSIST_CAP_BYTES = 4 * 1024 * 1024;
|
|
@@ -438,7 +477,8 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string):
|
|
|
438
477
|
}
|
|
439
478
|
|
|
440
479
|
function isTurnContinuation(reason: string): boolean {
|
|
441
|
-
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
480
|
+
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
481
|
+
|| reason === 'tool_result_guard_retry';
|
|
442
482
|
}
|
|
443
483
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
444
484
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
@@ -1212,7 +1252,36 @@ export class AgentFramework {
|
|
|
1212
1252
|
private mcplServerRegistry: McplServerRegistry | null = null;
|
|
1213
1253
|
private featureSetManager: FeatureSetManager | null = null;
|
|
1214
1254
|
private hookOrchestrator: HookOrchestrator | null = null;
|
|
1255
|
+
/** MCPL RFC-007: `tools/lifecycle` to observing servers. Created with the
|
|
1256
|
+
* MCPL subsystem; inert (no tracking) while no connection holds
|
|
1257
|
+
* `toolLifecycle.observe`. */
|
|
1258
|
+
private toolLifecycleEmitter: ToolLifecycleEmitter | null = null;
|
|
1259
|
+
/** MCPL RFC-008 class sources, normalized: operator overrides, then host
|
|
1260
|
+
* knowledge (embedding host's table ahead of the built-in one). */
|
|
1261
|
+
private toolClassOverrides: Array<[string, ToolClass[]]> = [];
|
|
1262
|
+
private hostToolClasses: Array<[string, ToolClass[]]> = normalizeClassTable(
|
|
1263
|
+
BUILTIN_TOOL_CLASSES as Record<string, readonly string[]>,
|
|
1264
|
+
'BUILTIN_TOOL_CLASSES',
|
|
1265
|
+
);
|
|
1215
1266
|
private pushHandler: PushHandler | null = null;
|
|
1267
|
+
/** RFC-006 coalescing (see mcpl/push-coalescer.ts for the design). */
|
|
1268
|
+
private pushCoalescer: PushCoalescer<CoalescedDelivery> | null = null;
|
|
1269
|
+
/** Both lanes are admitted in arrival order: a create, edit and delete of
|
|
1270
|
+
* one subject must not overtake one another while routing awaits. */
|
|
1271
|
+
private coalescingAdmission: Promise<unknown> = Promise.resolve();
|
|
1272
|
+
private coalescingSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1273
|
+
private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null;
|
|
1274
|
+
private coalescingRecentReceipts: CoalescingReceiptRecord[] = [];
|
|
1275
|
+
/**
|
|
1276
|
+
* Agents that read the residents' shared un-namespaced message slot: every
|
|
1277
|
+
* ordinary resident (their context managers are not isolated) and the
|
|
1278
|
+
* subconscious (isolated, but merging that slot read-only). A message in
|
|
1279
|
+
* that slot is unread only if NONE of them has compiled past it. Forks and
|
|
1280
|
+
* ephemeral agents have isolated slots and are their own only readers.
|
|
1281
|
+
*/
|
|
1282
|
+
private readonly sharedSlotAgents = new Set<Agent>();
|
|
1283
|
+
/** Group commit: one fsync per event-loop turn, shared by every waiter. */
|
|
1284
|
+
private coalescingCommit: Promise<void> | null = null;
|
|
1216
1285
|
private inferenceRouter: InferenceRouter | null = null;
|
|
1217
1286
|
private channelRegistry: ChannelRegistry | null = null;
|
|
1218
1287
|
private checkpointManager: CheckpointManager | null = null;
|
|
@@ -1265,10 +1334,15 @@ export class AgentFramework {
|
|
|
1265
1334
|
private mcplTools: import('./types/index.js').ToolDefinition[] = [];
|
|
1266
1335
|
/** Namespaced tool name → stateful feature-set attribution from tools/list. */
|
|
1267
1336
|
private mcplToolFeatureSets: Map<string, string> = new Map();
|
|
1337
|
+
/** Namespaced tool name → the server's RFC-008 `_meta["mcpl/class"]`. */
|
|
1338
|
+
private mcplToolClasses: Map<string, ToolClass[]> = new Map();
|
|
1268
1339
|
private mcplToolRefreshInFlight = false;
|
|
1269
1340
|
private mcplToolRefreshPending = false;
|
|
1270
1341
|
/** Maps tool prefix → serverId for dispatch routing. */
|
|
1271
1342
|
private mcplPrefixMap: Map<string, string> = new Map();
|
|
1343
|
+
/** Namespaced tool name → the server whose tools/list produced it. Prefixes
|
|
1344
|
+
* can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
|
|
1345
|
+
private mcplToolServers: Map<string, string> = new Map();
|
|
1272
1346
|
/** Maps serverId → McplServerConfig for prefix lookup. */
|
|
1273
1347
|
private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
|
|
1274
1348
|
/** Host capabilities advertised during the MCP handshake — stored so servers
|
|
@@ -1303,6 +1377,8 @@ export class AgentFramework {
|
|
|
1303
1377
|
|
|
1304
1378
|
// Session-level token usage tracking (always-on)
|
|
1305
1379
|
private usageTracker: UsageTracker;
|
|
1380
|
+
/** Explicit-send suppression carried into a tool-result-guard retry. */
|
|
1381
|
+
private guardRetryTurnSilenced = new Map<string, boolean>();
|
|
1306
1382
|
/** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
|
|
1307
1383
|
private readonly timeZone: string;
|
|
1308
1384
|
|
|
@@ -1364,6 +1440,9 @@ export class AgentFramework {
|
|
|
1364
1440
|
* Create and start the framework.
|
|
1365
1441
|
*/
|
|
1366
1442
|
static async create(config: FrameworkConfig): Promise<AgentFramework> {
|
|
1443
|
+
// Before anything opens or starts: a refused config must leave nothing behind.
|
|
1444
|
+
if (config.codeExecution?.enabled) validateCodeExecutionConfig(config.codeExecution);
|
|
1445
|
+
|
|
1367
1446
|
// Create or use existing store
|
|
1368
1447
|
let store: JsStore;
|
|
1369
1448
|
let ownsStore: boolean;
|
|
@@ -1389,6 +1468,13 @@ export class AgentFramework {
|
|
|
1389
1468
|
} catch {
|
|
1390
1469
|
// Already registered
|
|
1391
1470
|
}
|
|
1471
|
+
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
|
|
1472
|
+
try {
|
|
1473
|
+
store.registerState({ id, strategy: 'snapshot' });
|
|
1474
|
+
} catch {
|
|
1475
|
+
// Already registered
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1392
1478
|
|
|
1393
1479
|
try {
|
|
1394
1480
|
store.registerState({
|
|
@@ -1588,6 +1674,12 @@ export class AgentFramework {
|
|
|
1588
1674
|
// retained when enabled — everything downstream gates on the field.
|
|
1589
1675
|
framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
|
|
1590
1676
|
|
|
1677
|
+
framework.toolClassOverrides = normalizeClassTable(config.toolClassOverrides, 'toolClassOverrides');
|
|
1678
|
+
framework.hostToolClasses = [
|
|
1679
|
+
...normalizeClassTable(config.hostToolClasses, 'hostToolClasses'),
|
|
1680
|
+
...normalizeClassTable(BUILTIN_TOOL_CLASSES as Record<string, readonly string[]>, 'BUILTIN_TOOL_CLASSES'),
|
|
1681
|
+
];
|
|
1682
|
+
|
|
1591
1683
|
if (config.toolResultInlineMaxChars !== undefined) {
|
|
1592
1684
|
const cap = config.toolResultInlineMaxChars;
|
|
1593
1685
|
if (!Number.isFinite(cap) || cap < 1000) {
|
|
@@ -1781,6 +1873,8 @@ export class AgentFramework {
|
|
|
1781
1873
|
* Stop the event loop.
|
|
1782
1874
|
*/
|
|
1783
1875
|
async stop(): Promise<void> {
|
|
1876
|
+
this.pushCoalescer?.suspend();
|
|
1877
|
+
this.flushCoalescingSnapshot();
|
|
1784
1878
|
this.running = false;
|
|
1785
1879
|
// Flushed-but-unsynced deferred writes: sync and ack now, while the
|
|
1786
1880
|
// store is still open, rather than leaving them to a reboot replay.
|
|
@@ -1859,6 +1953,16 @@ export class AgentFramework {
|
|
|
1859
1953
|
}
|
|
1860
1954
|
await Promise.all(shutdownPromises);
|
|
1861
1955
|
|
|
1956
|
+
// Streams may queue storage repairs while being cancelled above. Retry
|
|
1957
|
+
// after their teardown, while the store is still open, before final sync.
|
|
1958
|
+
for (const agent of this.agents.values()) {
|
|
1959
|
+
try {
|
|
1960
|
+
agent.toolResultGuard.flushUnrecorded();
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
console.error(`[tool-result-guard] agent=${agent.name} could not finish queued storage work at stop:`, error);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1862
1966
|
// Final sync before closing
|
|
1863
1967
|
try {
|
|
1864
1968
|
this.store.sync();
|
|
@@ -2090,6 +2194,11 @@ export class AgentFramework {
|
|
|
2090
2194
|
|
|
2091
2195
|
private async runQueuedMaintenance(): Promise<void> {
|
|
2092
2196
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
2197
|
+
try {
|
|
2198
|
+
agent.toolResultGuard.flushUnrecorded();
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
|
|
2201
|
+
}
|
|
2093
2202
|
const cm = agent.getContextManager();
|
|
2094
2203
|
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
2095
2204
|
cm.setToolDefinitions(tools);
|
|
@@ -2398,6 +2507,11 @@ export class AgentFramework {
|
|
|
2398
2507
|
ext.keys.forEach((k) => taken.add(k));
|
|
2399
2508
|
result.set('_framework', ext);
|
|
2400
2509
|
}
|
|
2510
|
+
{
|
|
2511
|
+
const ext = this.toolResultGuardSettingsExtension();
|
|
2512
|
+
ext.keys.forEach((key) => taken.add(key));
|
|
2513
|
+
result.set('_toolResultGuard', ext);
|
|
2514
|
+
}
|
|
2401
2515
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
2402
2516
|
const ext = module.getAgentSettingsExtension?.();
|
|
2403
2517
|
if (!ext) continue;
|
|
@@ -2468,6 +2582,56 @@ export class AgentFramework {
|
|
|
2468
2582
|
};
|
|
2469
2583
|
}
|
|
2470
2584
|
|
|
2585
|
+
private toolResultGuardSettingsExtension(): AgentSettingsExtension {
|
|
2586
|
+
const get = (name: string): Record<string, unknown> => {
|
|
2587
|
+
const guard = this.agents.get(name)?.toolResultGuard;
|
|
2588
|
+
return {
|
|
2589
|
+
tool_result_guard: guard?.enabled ?? false,
|
|
2590
|
+
tool_result_guard_source: guard?.settingOverride !== undefined ? 'runtime_override' : 'recipe_default',
|
|
2591
|
+
};
|
|
2592
|
+
};
|
|
2593
|
+
const set = (name: string, enabled: boolean | undefined): Record<string, unknown> => {
|
|
2594
|
+
const agent = this.agents.get(name);
|
|
2595
|
+
if (!agent) throw new Error(`Unknown agent: ${name}`);
|
|
2596
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
2597
|
+
const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
|
|
2598
|
+
const values = { ...((state.toolResultGuards as Record<string, boolean> | undefined) ?? {}) };
|
|
2599
|
+
if (enabled === undefined) delete values[name];
|
|
2600
|
+
else values[name] = enabled;
|
|
2601
|
+
state.toolResultGuards = values;
|
|
2602
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
2603
|
+
agent.toolResultGuard.setOverride(enabled);
|
|
2604
|
+
return get(name);
|
|
2605
|
+
};
|
|
2606
|
+
return {
|
|
2607
|
+
properties: {
|
|
2608
|
+
tool_result_guard: {
|
|
2609
|
+
type: 'boolean',
|
|
2610
|
+
description: 'Enable the tool result guard. It can withhold a batch of tool output and retry inference once. ' +
|
|
2611
|
+
'Tools are not re-executed. Persists across turns and restarts until explicitly changed. ' +
|
|
2612
|
+
'Disabling affects future results only; previously withheld results stay withheld.',
|
|
2613
|
+
},
|
|
2614
|
+
},
|
|
2615
|
+
keys: ['tool_result_guard'],
|
|
2616
|
+
get,
|
|
2617
|
+
update: (name, patch) => {
|
|
2618
|
+
if (typeof patch.tool_result_guard !== 'boolean') throw new Error('tool_result_guard must be a boolean');
|
|
2619
|
+
return set(name, patch.tool_result_guard);
|
|
2620
|
+
},
|
|
2621
|
+
reset: (name) => set(name, undefined),
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
private restoreToolResultGuardSetting(agent: Agent): void {
|
|
2626
|
+
const enabled = (this.store.getStateJson(FRAMEWORK_STATE_ID) as {
|
|
2627
|
+
toolResultGuards?: Record<string, unknown>;
|
|
2628
|
+
} | null)?.toolResultGuards?.[agent.name];
|
|
2629
|
+
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
2630
|
+
throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
|
|
2631
|
+
}
|
|
2632
|
+
agent.toolResultGuard.setOverride(enabled);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2471
2635
|
private buildThinkTool(
|
|
2472
2636
|
policy: SameRoundThinkTextPolicy,
|
|
2473
2637
|
): import('./types/index.js').ToolDefinition {
|
|
@@ -2562,6 +2726,22 @@ export class AgentFramework {
|
|
|
2562
2726
|
});
|
|
2563
2727
|
}
|
|
2564
2728
|
|
|
2729
|
+
/**
|
|
2730
|
+
* The tools one agent is shown at inference: its surface (the subconscious
|
|
2731
|
+
* has its own), less what its permissions deny, plus — for explicit-mode
|
|
2732
|
+
* agents — the on-demand routing reference (teach-by-bounce: the grammar
|
|
2733
|
+
* is never injected, only served when asked). Inference and the RFC-008
|
|
2734
|
+
* listing both use this, so the listing cannot drift from the model's view.
|
|
2735
|
+
*/
|
|
2736
|
+
private agentToolSurface(
|
|
2737
|
+
agent: Agent,
|
|
2738
|
+
snapshot?: InferenceToolSnapshot,
|
|
2739
|
+
): import('./types/index.js').ToolDefinition[] {
|
|
2740
|
+
const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name));
|
|
2741
|
+
if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
|
|
2742
|
+
return tools;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2565
2745
|
getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
|
|
2566
2746
|
const agent = this.agents.get(agentName);
|
|
2567
2747
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -3849,6 +4029,8 @@ export class AgentFramework {
|
|
|
3849
4029
|
});
|
|
3850
4030
|
|
|
3851
4031
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
4032
|
+
this.restoreToolResultGuardSetting(agent);
|
|
4033
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
3852
4034
|
this.ephemeralCandidates.set(agent, contextManager);
|
|
3853
4035
|
|
|
3854
4036
|
const cleanup = () => {
|
|
@@ -6153,6 +6335,9 @@ export class AgentFramework {
|
|
|
6153
6335
|
});
|
|
6154
6336
|
|
|
6155
6337
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
6338
|
+
this.restoreToolResultGuardSetting(agent);
|
|
6339
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
6340
|
+
this.sharedSlotAgents.add(agent);
|
|
6156
6341
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
6157
6342
|
if (restoredSettings) {
|
|
6158
6343
|
agent.restoreRuntimeSettings(
|
|
@@ -6227,6 +6412,9 @@ export class AgentFramework {
|
|
|
6227
6412
|
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
6228
6413
|
};
|
|
6229
6414
|
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
6415
|
+
this.restoreToolResultGuardSetting(agent);
|
|
6416
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
6417
|
+
this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
|
|
6230
6418
|
this.agents.set(name, agent);
|
|
6231
6419
|
this.agentConfigs.set(name, agentConfig);
|
|
6232
6420
|
this.subconsciousAgentName = name;
|
|
@@ -6299,6 +6487,10 @@ export class AgentFramework {
|
|
|
6299
6487
|
return;
|
|
6300
6488
|
}
|
|
6301
6489
|
|
|
6490
|
+
// RFC-007: the call's terminal (completed / failed) — the result
|
|
6491
|
+
// itself is never sent, only whether it was an error.
|
|
6492
|
+
this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
|
|
6493
|
+
|
|
6302
6494
|
const agent = this.agents.get(event.agentName);
|
|
6303
6495
|
if (agent) {
|
|
6304
6496
|
this.touchEphemeralRun(event.agentName, true);
|
|
@@ -6348,7 +6540,10 @@ export class AgentFramework {
|
|
|
6348
6540
|
// divergence breaks the compile prefix).
|
|
6349
6541
|
const { blocks: toolResultContent, spilled } =
|
|
6350
6542
|
await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
|
|
6351
|
-
|
|
6543
|
+
const membraneResults = currentState.toolResults.map(tc =>
|
|
6544
|
+
this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
|
|
6545
|
+
);
|
|
6546
|
+
agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
|
|
6352
6547
|
|
|
6353
6548
|
// Flush any messages that were deferred while this turn was in
|
|
6354
6549
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
@@ -6531,6 +6726,9 @@ export class AgentFramework {
|
|
|
6531
6726
|
|
|
6532
6727
|
if (shouldEndTurn) {
|
|
6533
6728
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
6729
|
+
// The batch is never submitted, so nothing can refuse it: admit
|
|
6730
|
+
// it now instead of leaving it pending across the idle gap.
|
|
6731
|
+
agent.toolResultGuard.settleTurnEnded();
|
|
6534
6732
|
if (currentState.stream) {
|
|
6535
6733
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
|
|
6536
6734
|
currentState.stream.cancel();
|
|
@@ -6580,17 +6778,16 @@ export class AgentFramework {
|
|
|
6580
6778
|
reason: 'context_budget_restart',
|
|
6581
6779
|
source: 'framework',
|
|
6582
6780
|
timestamp: Date.now(),
|
|
6781
|
+
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
6782
|
+
ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
|
|
6583
6783
|
});
|
|
6584
6784
|
} else if (currentState.stream) {
|
|
6585
6785
|
// Streaming path: convert results and resume the stream.
|
|
6586
6786
|
// Mid-turn messages collected above ride along as injected user
|
|
6587
6787
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
6588
6788
|
// envelope so the next round of THIS turn hears them.
|
|
6589
|
-
const membraneResults = currentState.toolResults.map(tc =>
|
|
6590
|
-
this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
|
|
6591
|
-
);
|
|
6592
6789
|
currentState.stream.provideToolResults(
|
|
6593
|
-
membraneResults,
|
|
6790
|
+
agent.toolResultGuard.submissionResults(membraneResults),
|
|
6594
6791
|
midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
|
|
6595
6792
|
);
|
|
6596
6793
|
agent.setStreaming(currentState.stream);
|
|
@@ -6632,6 +6829,17 @@ export class AgentFramework {
|
|
|
6632
6829
|
this.handleMcplPushEvent(event as unknown as McplPushEvent);
|
|
6633
6830
|
}
|
|
6634
6831
|
|
|
6832
|
+
await this.dispatchProcessEventToModules(event, startTime);
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
/**
|
|
6836
|
+
* The module half of handleProcessEvent: every module's onProcess, their
|
|
6837
|
+
* responses, the tool-call dispatch, the completion trace and the process
|
|
6838
|
+
* log. Shared with RFC-006 coalesced deliveries, which bypass the queue
|
|
6839
|
+
* (the coalescer needs the placement synchronously) but must still reach
|
|
6840
|
+
* modules exactly like the ordinary deliveries do.
|
|
6841
|
+
*/
|
|
6842
|
+
private async dispatchProcessEventToModules(event: ProcessEvent, startTime = Date.now()): Promise<void> {
|
|
6635
6843
|
// Dispatch to all modules, tracking responses with module names
|
|
6636
6844
|
const responses: ModuleProcessResponse[] = [];
|
|
6637
6845
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
@@ -6755,9 +6963,16 @@ export class AgentFramework {
|
|
|
6755
6963
|
tags?: string[];
|
|
6756
6964
|
triggerInference?: boolean;
|
|
6757
6965
|
targetAgents?: string[];
|
|
6758
|
-
|
|
6966
|
+
/** RFC-006: set when the coalescer delivers; rides the wake it queues. */
|
|
6967
|
+
coalescingSubject?: string;
|
|
6968
|
+
eventId?: string;
|
|
6969
|
+
assemblingFor?: string;
|
|
6970
|
+
deliverTo?: string;
|
|
6971
|
+
}): Promise<CoalescingPlacement | undefined> {
|
|
6759
6972
|
const metadata: Record<string, unknown> = {
|
|
6760
6973
|
...event.metadata,
|
|
6974
|
+
...(event.eventId ? { eventId: event.eventId } : {}),
|
|
6975
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6761
6976
|
channelId: event.channelId,
|
|
6762
6977
|
messageId: event.messageId,
|
|
6763
6978
|
author: event.author,
|
|
@@ -6770,9 +6985,36 @@ export class AgentFramework {
|
|
|
6770
6985
|
// Per-channel conversation routing: messages go to the channel's fork
|
|
6771
6986
|
// agent (spawned from the template on first qualifying message), never
|
|
6772
6987
|
// to the primary conversation.
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6988
|
+
// RFC-006 §3.2: a replacement or deletion notice follows the delivery it
|
|
6989
|
+
// corrects — into the agent that holds (or read) the prior occurrence,
|
|
6990
|
+
// never a context fresh routing would pick. Gone agent → delivered nowhere.
|
|
6991
|
+
if (event.deliverTo) {
|
|
6992
|
+
const target = this.agents.get(event.deliverTo);
|
|
6993
|
+
if (!target) return undefined;
|
|
6994
|
+
if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
|
|
6995
|
+
metadata.triggered = event.triggerInference ?? false;
|
|
6996
|
+
const id = target.getContextManager().addMessage('user', event.content, metadata);
|
|
6997
|
+
// A correction may target an older engagement. Refresh only the
|
|
6998
|
+
// binding whose agent actually received the message.
|
|
6999
|
+
if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
|
|
7000
|
+
this.conversationRouter.touch(event.channelId);
|
|
7001
|
+
}
|
|
7002
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
7003
|
+
if (event.triggerInference) {
|
|
7004
|
+
this.pendingRequests.push({
|
|
7005
|
+
agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
|
|
7006
|
+
channelId: event.channelId,
|
|
7007
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
7008
|
+
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
7009
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
7010
|
+
});
|
|
7011
|
+
}
|
|
7012
|
+
return { agent: target.name, messageId: id };
|
|
7013
|
+
}
|
|
7014
|
+
if (this.conversationRouter) return undefined; // the trunk never holds channel traffic
|
|
7015
|
+
event = { ...event, targetAgents: [target.name] };
|
|
7016
|
+
} else if (this.conversationRouter) {
|
|
7017
|
+
return this.routeConversationIncoming(event, metadata);
|
|
6776
7018
|
}
|
|
6777
7019
|
|
|
6778
7020
|
// Addressed-while-closed invitation — parity with the push-event path
|
|
@@ -6819,7 +7061,8 @@ export class AgentFramework {
|
|
|
6819
7061
|
metadata.tuneOut = { epochId: divert.epochId };
|
|
6820
7062
|
}
|
|
6821
7063
|
|
|
6822
|
-
const
|
|
7064
|
+
const placement: CoalescingPlacement = { agent: '' };
|
|
7065
|
+
const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
6823
7066
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
6824
7067
|
|
|
6825
7068
|
if (event.triggerInference && !divert) {
|
|
@@ -6845,9 +7088,11 @@ export class AgentFramework {
|
|
|
6845
7088
|
// Addressed messages outrank ambient chatter when a batched wake
|
|
6846
7089
|
// picks the turn's frozen speech locus.
|
|
6847
7090
|
addressed,
|
|
7091
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6848
7092
|
});
|
|
6849
7093
|
}
|
|
6850
7094
|
}
|
|
7095
|
+
return placement.agent ? placement : undefined;
|
|
6851
7096
|
}
|
|
6852
7097
|
|
|
6853
7098
|
/**
|
|
@@ -6866,9 +7111,10 @@ export class AgentFramework {
|
|
|
6866
7111
|
metadata?: Record<string, unknown>;
|
|
6867
7112
|
tags?: string[];
|
|
6868
7113
|
triggerInference?: boolean;
|
|
7114
|
+
coalescingSubject?: string;
|
|
6869
7115
|
},
|
|
6870
7116
|
messageMetadata: Record<string, unknown>,
|
|
6871
|
-
): Promise<
|
|
7117
|
+
): Promise<CoalescingPlacement | undefined> {
|
|
6872
7118
|
const router = this.conversationRouter!;
|
|
6873
7119
|
const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
|
|
6874
7120
|
|
|
@@ -6932,6 +7178,7 @@ export class AgentFramework {
|
|
|
6932
7178
|
messageMetadata.triggered = trigger;
|
|
6933
7179
|
|
|
6934
7180
|
const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
|
|
7181
|
+
router.touch(event.channelId);
|
|
6935
7182
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
6936
7183
|
|
|
6937
7184
|
if (trigger) {
|
|
@@ -6945,8 +7192,10 @@ export class AgentFramework {
|
|
|
6945
7192
|
channelId: event.channelId,
|
|
6946
7193
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
6947
7194
|
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
7195
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6948
7196
|
});
|
|
6949
7197
|
}
|
|
7198
|
+
return { agent: agent.name, messageId: id };
|
|
6950
7199
|
}
|
|
6951
7200
|
|
|
6952
7201
|
/**
|
|
@@ -6999,6 +7248,8 @@ export class AgentFramework {
|
|
|
6999
7248
|
|
|
7000
7249
|
const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
|
|
7001
7250
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
7251
|
+
this.restoreToolResultGuardSetting(agent);
|
|
7252
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
7002
7253
|
this.agents.set(name, agent);
|
|
7003
7254
|
this.agentConfigs.set(name, config);
|
|
7004
7255
|
this.conversationAgentHomes.set(name, channelId);
|
|
@@ -7177,6 +7428,19 @@ export class AgentFramework {
|
|
|
7177
7428
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
7178
7429
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
7179
7430
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
7431
|
+
// The label is for reading; the prefix must be ONE token that resolves back
|
|
7432
|
+
// here, on this server (a DM label "DM: alice" quoted verbatim parses as
|
|
7433
|
+
// target "#DM:"). A registry that can name targets is authoritative: no safe
|
|
7434
|
+
// token means no prefix option. A stand-in without proseTargetFor gets a
|
|
7435
|
+
// whitespace-free guess, never a label with spaces.
|
|
7436
|
+
const namer = this.channelRegistry as { proseTargetFor?: (id: string, serverId?: string) => string | undefined };
|
|
7437
|
+
const target = typeof namer.proseTargetFor === 'function'
|
|
7438
|
+
? namer.proseTargetFor(opts.channelId, opts.serverId)
|
|
7439
|
+
: [label ? `#${label}` : undefined, opts.channelId].find((t): t is string => !!t && !/\s/.test(t));
|
|
7440
|
+
const replyOption = target
|
|
7441
|
+
? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
|
|
7442
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
|
|
7443
|
+
: `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
|
|
7180
7444
|
const missedCount = opts.missedMessages ?? 0;
|
|
7181
7445
|
const missedNote =
|
|
7182
7446
|
missedCount > 0
|
|
@@ -7192,8 +7456,7 @@ export class AgentFramework {
|
|
|
7192
7456
|
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
7193
7457
|
missedNote +
|
|
7194
7458
|
`Your options:\n` +
|
|
7195
|
-
|
|
7196
|
-
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
|
|
7459
|
+
replyOption +
|
|
7197
7460
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
7198
7461
|
(maxBackscroll > 0
|
|
7199
7462
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -7213,10 +7476,454 @@ export class AgentFramework {
|
|
|
7213
7476
|
};
|
|
7214
7477
|
}
|
|
7215
7478
|
|
|
7479
|
+
// ==========================================================================
|
|
7480
|
+
// RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
|
|
7481
|
+
// ==========================================================================
|
|
7482
|
+
|
|
7483
|
+
/**
|
|
7484
|
+
* RFC-006 §3.2 binding identity: the configured server id bound to its
|
|
7485
|
+
* endpoint/command. Survives reconnects to the same peer; changes when the
|
|
7486
|
+
* id is reassigned to another endpoint (restartMcplServer with a new config).
|
|
7487
|
+
*/
|
|
7488
|
+
private coalescingBinding(serverId: string): string {
|
|
7489
|
+
const config = this.mcplServerConfigs.get(serverId);
|
|
7490
|
+
return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
|
|
7491
|
+
}
|
|
7492
|
+
|
|
7493
|
+
private initializePushCoalescer(): void {
|
|
7494
|
+
const coalescer = new PushCoalescer<CoalescedDelivery>({
|
|
7495
|
+
isUnread: (p) => this.isUnreadPlacement(p),
|
|
7496
|
+
remove: (p) => this.removePlacement(p),
|
|
7497
|
+
deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
|
|
7498
|
+
wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
|
|
7499
|
+
cancelWake: (subject) => this.cancelCoalescedWakes(subject),
|
|
7500
|
+
authorized: (occ) => this.coalescedAuthorized(occ),
|
|
7501
|
+
audience: (occ) => this.coalescedAudience(occ),
|
|
7502
|
+
render: (occ, params) => {
|
|
7503
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
7504
|
+
if (!server?.isConnected) throw new Error('server disconnected');
|
|
7505
|
+
return server.sendPushRender(params);
|
|
7506
|
+
},
|
|
7507
|
+
audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
|
|
7508
|
+
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
|
|
7509
|
+
recordReceipt: (record) => {
|
|
7510
|
+
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
7511
|
+
// must not lose its receipt, the subject's birth, or (deferred) the
|
|
7512
|
+
// accepted work itself to a crash. A failure here fails the acceptance.
|
|
7513
|
+
this.coalescingRecentReceipts.push(record);
|
|
7514
|
+
this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
|
|
7515
|
+
},
|
|
7516
|
+
wasPublished: (subject, eventId) => {
|
|
7517
|
+
// Boot-time only: the occurrence's durable delivery identity
|
|
7518
|
+
// (subject + eventId in message metadata) in any agent's context.
|
|
7519
|
+
for (const agent of this.agents.values()) {
|
|
7520
|
+
try {
|
|
7521
|
+
if (agent.getContextManager().getAllMessages().some((m) =>
|
|
7522
|
+
m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId)) return true;
|
|
7523
|
+
} catch { /* a context that cannot be read cannot prove publication */ }
|
|
7524
|
+
}
|
|
7525
|
+
return false;
|
|
7526
|
+
},
|
|
7527
|
+
commit: () => this.commitCoalescingDurable(),
|
|
7528
|
+
saveNow: (snapshot) => {
|
|
7529
|
+
if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
|
|
7530
|
+
this.coalescingSnapshotDirty = null;
|
|
7531
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot);
|
|
7532
|
+
this.coalescingRecentReceipts = [];
|
|
7533
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
7534
|
+
},
|
|
7535
|
+
});
|
|
7536
|
+
try {
|
|
7537
|
+
coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID) as CoalescingSnapshot | null);
|
|
7538
|
+
const recent = this.store.getStateJson(COALESCING_RECENT_ID) as CoalescingReceiptRecord[] | null;
|
|
7539
|
+
if (Array.isArray(recent)) {
|
|
7540
|
+
coalescer.restoreReceipts(recent);
|
|
7541
|
+
// Until the next snapshot flush checkpoints the recovered state, these
|
|
7542
|
+
// records are its only durable form: keep them in the writable buffer
|
|
7543
|
+
// so the next acceptance appends to them instead of replacing them.
|
|
7544
|
+
this.coalescingRecentReceipts = recent;
|
|
7545
|
+
}
|
|
7546
|
+
} catch (err) {
|
|
7547
|
+
console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
|
|
7548
|
+
}
|
|
7549
|
+
this.pushCoalescer = coalescer;
|
|
7550
|
+
// Batches restored from a snapshot still owe the agent a wake (§14 v41).
|
|
7551
|
+
for (const occ of coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ);
|
|
7552
|
+
}
|
|
7553
|
+
|
|
7554
|
+
/**
|
|
7555
|
+
* Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
|
|
7556
|
+
* written so far is fsynced before the caller proceeds. Group-committed —
|
|
7557
|
+
* all acceptances that arrive in one event-loop turn share one
|
|
7558
|
+
* `store.sync()` — so throughput is bounded by fsync rate per batch, not
|
|
7559
|
+
* per message.
|
|
7560
|
+
*/
|
|
7561
|
+
private commitCoalescingDurable(): Promise<void> {
|
|
7562
|
+
if (!this.coalescingCommit) {
|
|
7563
|
+
this.coalescingCommit = new Promise<void>((resolve, reject) => {
|
|
7564
|
+
setImmediate(() => {
|
|
7565
|
+
this.coalescingCommit = null;
|
|
7566
|
+
try { this.store.sync(); resolve(); } catch (err) { reject(err); }
|
|
7567
|
+
});
|
|
7568
|
+
});
|
|
7569
|
+
}
|
|
7570
|
+
return this.coalescingCommit;
|
|
7571
|
+
}
|
|
7572
|
+
|
|
7573
|
+
private scheduleCoalescingSnapshot(snapshot: () => CoalescingSnapshot): void {
|
|
7574
|
+
this.coalescingSnapshotDirty = snapshot;
|
|
7575
|
+
if (this.coalescingSaveTimer) return;
|
|
7576
|
+
const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
|
|
7577
|
+
timer.unref?.();
|
|
7578
|
+
this.coalescingSaveTimer = timer;
|
|
7579
|
+
}
|
|
7580
|
+
|
|
7581
|
+
private flushCoalescingSnapshot(): void {
|
|
7582
|
+
if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
|
|
7583
|
+
const snapshot = this.coalescingSnapshotDirty;
|
|
7584
|
+
this.coalescingSnapshotDirty = null;
|
|
7585
|
+
if (!snapshot) return;
|
|
7586
|
+
try {
|
|
7587
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot());
|
|
7588
|
+
this.coalescingRecentReceipts = [];
|
|
7589
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
7590
|
+
} catch (err) {
|
|
7591
|
+
console.error('[coalescing] could not persist coalescing state:', err);
|
|
7592
|
+
}
|
|
7593
|
+
}
|
|
7594
|
+
|
|
7595
|
+
/** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
|
|
7596
|
+
private admitCoalesced<T>(work: () => Promise<T>): Promise<T> {
|
|
7597
|
+
const run = this.coalescingAdmission.then(work, work);
|
|
7598
|
+
this.coalescingAdmission = run.then(() => undefined, () => undefined);
|
|
7599
|
+
return run;
|
|
7600
|
+
}
|
|
7601
|
+
|
|
7602
|
+
private handleCoalescedIncoming(
|
|
7603
|
+
serverId: string,
|
|
7604
|
+
message: ChannelIncomingMessage,
|
|
7605
|
+
event: CoalescedChannelEvent,
|
|
7606
|
+
): Promise<ChannelIncomingMessageResult> {
|
|
7607
|
+
return this.admitCoalesced(async () => {
|
|
7608
|
+
if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
7609
|
+
validateCoalesceMember(message.coalesce, 'channel');
|
|
7610
|
+
if (typeof message.eventId !== 'string' || !message.eventId) {
|
|
7611
|
+
throw new CoalesceError('eventId', 'eventId is required with coalesce');
|
|
7612
|
+
}
|
|
7613
|
+
if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7614
|
+
validateCoalescedContent(message.content);
|
|
7615
|
+
const c = message.coalesce;
|
|
7616
|
+
const result = await this.pushCoalescer.accept({
|
|
7617
|
+
serverId,
|
|
7618
|
+
binding: this.coalescingBinding(serverId),
|
|
7619
|
+
scope: { kind: 'channel', id: message.channelId },
|
|
7620
|
+
key: c.key,
|
|
7621
|
+
eventId: message.eventId,
|
|
7622
|
+
timestamp: message.timestamp,
|
|
7623
|
+
retract: !!c.retract,
|
|
7624
|
+
deferred: false,
|
|
7625
|
+
initial: !!c.initial,
|
|
7626
|
+
tags: event.tags,
|
|
7627
|
+
content: message.content,
|
|
7628
|
+
identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
|
|
7629
|
+
event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
|
|
7630
|
+
});
|
|
7631
|
+
return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
|
|
7632
|
+
});
|
|
7633
|
+
}
|
|
7634
|
+
|
|
7635
|
+
private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise<PushEventResult> {
|
|
7636
|
+
return this.admitCoalesced(async () => {
|
|
7637
|
+
if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
7638
|
+
validateCoalesceMember(params.coalesce, 'push');
|
|
7639
|
+
if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
|
|
7640
|
+
if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7641
|
+
validateCoalescedContent(params.payload?.content);
|
|
7642
|
+
const c = params.coalesce;
|
|
7643
|
+
if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId);
|
|
7644
|
+
const origin = params.origin ?? {};
|
|
7645
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
7646
|
+
return this.pushCoalescer.accept({
|
|
7647
|
+
serverId,
|
|
7648
|
+
binding: this.coalescingBinding(serverId),
|
|
7649
|
+
featureSet: params.featureSet,
|
|
7650
|
+
scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
|
|
7651
|
+
key: c.key,
|
|
7652
|
+
eventId: params.eventId,
|
|
7653
|
+
timestamp: params.timestamp,
|
|
7654
|
+
retract: !!c.retract,
|
|
7655
|
+
deferred: !!c.deferred,
|
|
7656
|
+
initial: !!c.initial,
|
|
7657
|
+
data: c.data,
|
|
7658
|
+
tags: event.tags,
|
|
7659
|
+
content: params.payload.content,
|
|
7660
|
+
...(c.channelId ? {
|
|
7661
|
+
identity: {
|
|
7662
|
+
...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
|
|
7663
|
+
...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
|
|
7664
|
+
...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}),
|
|
7665
|
+
},
|
|
7666
|
+
} : {}),
|
|
7667
|
+
event: { lane: 'push', event },
|
|
7668
|
+
});
|
|
7669
|
+
});
|
|
7670
|
+
}
|
|
7671
|
+
|
|
7672
|
+
/** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
|
|
7673
|
+
private assertChannelScopedPush(serverId: string, channelId: string): void {
|
|
7674
|
+
const server = this.mcplServerRegistry?.getServer(serverId);
|
|
7675
|
+
if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
|
|
7676
|
+
throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
|
|
7677
|
+
}
|
|
7678
|
+
if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
|
|
7679
|
+
throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
|
|
7680
|
+
}
|
|
7681
|
+
}
|
|
7682
|
+
|
|
7683
|
+
private coalescedAuthorized(occ: CoalescedOccurrence<CoalescedDelivery>): boolean {
|
|
7684
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
7685
|
+
if (!server?.isConnected || !server.policyEstablished) return false;
|
|
7686
|
+
// §3.2: work admitted under another binding (the id since reassigned to a
|
|
7687
|
+
// different endpoint) never acquires the replacement peer's authority —
|
|
7688
|
+
// and its private notice data is never sent to that peer.
|
|
7689
|
+
if (occ.binding !== this.coalescingBinding(occ.serverId)) return false;
|
|
7690
|
+
if (occ.event.lane === 'push') {
|
|
7691
|
+
if (!server.grant.has('pushEvents')) return false;
|
|
7692
|
+
try { this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet); } catch { return false; }
|
|
7693
|
+
}
|
|
7694
|
+
if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
|
|
7695
|
+
// §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
|
|
7696
|
+
if (!server.grant.has('channels.incoming')) return false;
|
|
7697
|
+
if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
|
|
7698
|
+
return !!this.channelRegistry?.getDescriptor(occ.scope.id);
|
|
7699
|
+
}
|
|
7700
|
+
return true;
|
|
7701
|
+
}
|
|
7702
|
+
|
|
7703
|
+
private async coalescedAudience(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<string[]> {
|
|
7704
|
+
if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
|
|
7705
|
+
const explicit = occ.event.event.targetAgents;
|
|
7706
|
+
if (explicit) return explicit.filter((n) => this.agents.has(n));
|
|
7707
|
+
if (occ.scope.kind === 'channel' && this.conversationRouter) {
|
|
7708
|
+
const router = this.conversationRouter;
|
|
7709
|
+
const bound = router.getBinding(occ.scope.id)?.agentName;
|
|
7710
|
+
if (bound) return this.agents.has(bound) ? [bound] : [];
|
|
7711
|
+
// No fork yet: the same bind decision an incoming message would get. A
|
|
7712
|
+
// batch that qualifies spawns its fork now so it has someone to wake.
|
|
7713
|
+
const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
|
|
7714
|
+
const decision = router.route({
|
|
7715
|
+
channelId: occ.scope.id,
|
|
7716
|
+
mentioned: metadata.mentioned === true,
|
|
7717
|
+
kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
|
|
7718
|
+
});
|
|
7719
|
+
if (decision.kind !== 'spawn') return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
|
|
7720
|
+
try {
|
|
7721
|
+
await this.createConversationAgent(decision.agentName, occ.scope.id);
|
|
7722
|
+
router.bind(occ.scope.id, decision.agentName, decision.generation);
|
|
7723
|
+
this.persistConversationRouterState();
|
|
7724
|
+
return [decision.agentName];
|
|
7725
|
+
} catch (err) {
|
|
7726
|
+
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
|
|
7727
|
+
return [];
|
|
7728
|
+
}
|
|
7729
|
+
}
|
|
7730
|
+
return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
7731
|
+
}
|
|
7732
|
+
|
|
7733
|
+
/** Deliver through the lane's ordinary path; report where it landed. */
|
|
7734
|
+
private async deliverCoalesced(
|
|
7735
|
+
occ: CoalescedOccurrence<CoalescedDelivery>,
|
|
7736
|
+
materialized?: McplContentBlock[],
|
|
7737
|
+
assemblingFor?: string,
|
|
7738
|
+
): Promise<CoalescingPlacement | undefined> {
|
|
7739
|
+
const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
7740
|
+
const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
|
|
7741
|
+
if (occ.event.lane === 'channel') {
|
|
7742
|
+
const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
|
|
7743
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
7744
|
+
await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
|
|
7745
|
+
return placement;
|
|
7746
|
+
}
|
|
7747
|
+
if (occ.scope.kind === 'channel') {
|
|
7748
|
+
// §3.2: a channel-scoped push addresses the channel's subject namespace,
|
|
7749
|
+
// so it is delivered as a message OF that channel — same routing
|
|
7750
|
+
// (conversation forks, tune-out, closed-channel invitation), same reply
|
|
7751
|
+
// locus, and the subject's stable platform identity (§3.1, vector 43).
|
|
7752
|
+
const push = occ.event.event;
|
|
7753
|
+
const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
|
|
7754
|
+
const origin = push.origin ?? {};
|
|
7755
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
7756
|
+
const event: CoalescedChannelEvent = {
|
|
7757
|
+
type: 'mcpl:channel-incoming',
|
|
7758
|
+
serverId: occ.serverId,
|
|
7759
|
+
channelId: occ.scope.id,
|
|
7760
|
+
messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
|
|
7761
|
+
threadId: identity.threadId ?? str(origin.threadId),
|
|
7762
|
+
author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
|
|
7763
|
+
content: materialized ? materialized.map(convertPushBlock) : push.content,
|
|
7764
|
+
timestamp: occ.timestamp,
|
|
7765
|
+
metadata: origin,
|
|
7766
|
+
tags: push.tags,
|
|
7767
|
+
triggerInference: assemblingFor ? false : push.triggerInference,
|
|
7768
|
+
targetAgents: push.targetAgents,
|
|
7769
|
+
coalescingSubject,
|
|
7770
|
+
eventId: occ.eventId,
|
|
7771
|
+
...narrowing,
|
|
7772
|
+
...(assemblingFor ? { assemblingFor } : {}),
|
|
7773
|
+
};
|
|
7774
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
7775
|
+
await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
|
|
7776
|
+
return placement;
|
|
7777
|
+
}
|
|
7778
|
+
const event: McplPushEvent = {
|
|
7779
|
+
...occ.event.event,
|
|
7780
|
+
eventId: occ.eventId,
|
|
7781
|
+
timestamp: occ.timestamp,
|
|
7782
|
+
...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
|
|
7783
|
+
coalescingSubject,
|
|
7784
|
+
// §5.2: the materialized occurrence is consumed by the request being
|
|
7785
|
+
// assembled; the wake that started it is not repeated.
|
|
7786
|
+
...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
|
|
7787
|
+
...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
|
|
7788
|
+
};
|
|
7789
|
+
const placement = this.handleMcplPushEvent(event);
|
|
7790
|
+
await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
|
|
7791
|
+
return placement;
|
|
7792
|
+
}
|
|
7793
|
+
|
|
7794
|
+
private async wakeForCoalescedBatch(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<void> {
|
|
7795
|
+
const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
7796
|
+
this.cancelCoalescedWakes(subject);
|
|
7797
|
+
if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return;
|
|
7798
|
+
const origin = occ.event.event.origin;
|
|
7799
|
+
// The wake carries the batch's channel so the turn freezes its reply
|
|
7800
|
+
// locus there (the materialized delivery at assembly wakes nobody).
|
|
7801
|
+
const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
|
|
7802
|
+
const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
|
|
7803
|
+
for (const agentName of await this.coalescedAudience(occ)) {
|
|
7804
|
+
this.pendingRequests.push({
|
|
7805
|
+
agentName,
|
|
7806
|
+
reason: 'mcpl:push-event',
|
|
7807
|
+
source: occ.serverId,
|
|
7808
|
+
timestamp: Date.now(),
|
|
7809
|
+
channelId,
|
|
7810
|
+
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
7811
|
+
addressed: isAddressedMessage(occ.tags, origin),
|
|
7812
|
+
coalescingSubject: subject,
|
|
7813
|
+
});
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
|
|
7817
|
+
/** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
|
|
7818
|
+
private cancelCoalescedWakes(subject: string): void {
|
|
7819
|
+
this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
|
|
7820
|
+
for (const cooldown of this.providerAccelerationCooldowns.values()) {
|
|
7821
|
+
cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
|
|
7822
|
+
}
|
|
7823
|
+
}
|
|
7824
|
+
|
|
7825
|
+
/**
|
|
7826
|
+
* A placement in the deferred queue is upgraded in place once the flush has
|
|
7827
|
+
* stored it: the stored copy carries `metadata.deferredWriteId`, found by
|
|
7828
|
+
* scanning the unread tail (above the watermark) — never below it.
|
|
7829
|
+
*/
|
|
7830
|
+
private resolvePlacement(p: CoalescingPlacement): void {
|
|
7831
|
+
if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId)) return;
|
|
7832
|
+
const agent = this.agents.get(p.agent);
|
|
7833
|
+
if (!agent) return;
|
|
7834
|
+
const cm = agent.getContextManager();
|
|
7835
|
+
const watermark = agent.getConsumedWatermark();
|
|
7836
|
+
const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
|
|
7837
|
+
const all = cm.getAllMessages();
|
|
7838
|
+
for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
|
|
7839
|
+
if (all[i].metadata?.deferredWriteId === p.deferredId) {
|
|
7840
|
+
p.messageId = all[i].id;
|
|
7841
|
+
delete p.deferredId;
|
|
7842
|
+
return;
|
|
7843
|
+
}
|
|
7844
|
+
}
|
|
7845
|
+
}
|
|
7846
|
+
|
|
7847
|
+
private isUnreadPlacement(p: CoalescingPlacement): boolean {
|
|
7848
|
+
this.resolvePlacement(p);
|
|
7849
|
+
if (p.deferredId) return this.deferredMessages.some((m) => m.id === p.deferredId);
|
|
7850
|
+
if (!p.messageId) return false;
|
|
7851
|
+
const agent = this.agents.get(p.agent);
|
|
7852
|
+
return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
|
|
7853
|
+
}
|
|
7854
|
+
|
|
7855
|
+
/**
|
|
7856
|
+
* Unread = stored, above the agent's consumed watermark on the current
|
|
7857
|
+
* branch, not a body-group shard (immutable), and not folded into any
|
|
7858
|
+
* summary (compression is consumption, §3.3). Conservative on every doubt.
|
|
7859
|
+
*/
|
|
7860
|
+
private isUnreadStoredMessage(agent: Agent, messageId: MessageId): boolean {
|
|
7861
|
+
const cm = agent.getContextManager();
|
|
7862
|
+
let message: ReturnType<ContextManager['getMessage']>;
|
|
7863
|
+
try { message = cm.getMessage(messageId); } catch { return false; }
|
|
7864
|
+
if (!message || message.bodyGroupId) return false;
|
|
7865
|
+
// Every agent that can read this message must not have compiled past it.
|
|
7866
|
+
// Residents share one message slot, so a message one of them read is
|
|
7867
|
+
// history for all of them (the never-rewrite rule is about the message,
|
|
7868
|
+
// not the agent that happened to be asked). A tune-out-diverted message
|
|
7869
|
+
// never enters a resident's compiled view; only the subconscious reads
|
|
7870
|
+
// it through its merged view — no subconscious, nobody reads it.
|
|
7871
|
+
let readers: Agent[];
|
|
7872
|
+
if ((message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) {
|
|
7873
|
+
const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
|
|
7874
|
+
if (!subconscious) return true;
|
|
7875
|
+
readers = [subconscious];
|
|
7876
|
+
} else {
|
|
7877
|
+
readers = this.sharedSlotAgents.has(agent)
|
|
7878
|
+
? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
|
|
7879
|
+
: [agent];
|
|
7880
|
+
}
|
|
7881
|
+
const branch = cm.currentBranch().name;
|
|
7882
|
+
const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
|
|
7883
|
+
for (const reader of readers) {
|
|
7884
|
+
const watermark = reader.getConsumedWatermark();
|
|
7885
|
+
if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence) return false;
|
|
7886
|
+
// Compression is consumption (§3.3): folded into any reader's summary.
|
|
7887
|
+
try {
|
|
7888
|
+
const rcm = reader.getContextManager();
|
|
7889
|
+
const strategy = rcm.getStrategy() as { getSummary?: (id: string) => { sourceIds?: string[]; sourceLevel?: number } | null };
|
|
7890
|
+
for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
|
|
7891
|
+
const entry = strategy.getSummary?.(summary.id);
|
|
7892
|
+
if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId)) return false;
|
|
7893
|
+
}
|
|
7894
|
+
} catch {
|
|
7895
|
+
return false;
|
|
7896
|
+
}
|
|
7897
|
+
}
|
|
7898
|
+
return true;
|
|
7899
|
+
}
|
|
7900
|
+
|
|
7901
|
+
private removePlacement(p: CoalescingPlacement): boolean {
|
|
7902
|
+
this.resolvePlacement(p);
|
|
7903
|
+
if (p.deferredId) {
|
|
7904
|
+
const before = this.deferredMessages.length;
|
|
7905
|
+
this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
|
|
7906
|
+
if (this.deferredMessages.length === before) return false;
|
|
7907
|
+
if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
|
|
7908
|
+
return true;
|
|
7909
|
+
}
|
|
7910
|
+
if (!p.messageId) return false;
|
|
7911
|
+
const agent = this.agents.get(p.agent);
|
|
7912
|
+
if (!agent) return false;
|
|
7913
|
+
try {
|
|
7914
|
+
agent.getContextManager().removeMessage(p.messageId);
|
|
7915
|
+
this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
|
|
7916
|
+
return true;
|
|
7917
|
+
} catch (err) {
|
|
7918
|
+
console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
|
|
7919
|
+
return false;
|
|
7920
|
+
}
|
|
7921
|
+
}
|
|
7922
|
+
|
|
7216
7923
|
/**
|
|
7217
7924
|
* Convert an MCPL push event to a context message.
|
|
7218
7925
|
*/
|
|
7219
|
-
private handleMcplPushEvent(event: McplPushEvent):
|
|
7926
|
+
private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined {
|
|
7220
7927
|
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
7221
7928
|
if (triggerChannel && this.channelRegistry) {
|
|
7222
7929
|
this.channelRegistry.ensureChannelRegistered(
|
|
@@ -7234,6 +7941,7 @@ export class AgentFramework {
|
|
|
7234
7941
|
eventId: event.eventId,
|
|
7235
7942
|
triggered: event.triggerInference ?? false,
|
|
7236
7943
|
...(event.tags ? { tags: event.tags } : {}),
|
|
7944
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
7237
7945
|
};
|
|
7238
7946
|
// `origin.channelId` is the server-internal raw id (a bare Discord
|
|
7239
7947
|
// snowflake) — unroutable as a locus and unresolvable by the agent. Store
|
|
@@ -7266,8 +7974,24 @@ export class AgentFramework {
|
|
|
7266
7974
|
}
|
|
7267
7975
|
}
|
|
7268
7976
|
|
|
7269
|
-
|
|
7270
|
-
|
|
7977
|
+
// Silent heartbeat ticks are control-plane wakes, not autobiography.
|
|
7978
|
+
// Accept the no-message path only for the heartbeat feature's own exact
|
|
7979
|
+
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
7980
|
+
// content merely by setting `origin.silent`.
|
|
7981
|
+
const silentHeartbeat =
|
|
7982
|
+
event.serverId === 'heartbeat' &&
|
|
7983
|
+
event.featureSet === 'heartbeat' &&
|
|
7984
|
+
event.origin?.source === 'heartbeat' &&
|
|
7985
|
+
event.origin?.silent === true &&
|
|
7986
|
+
content.length === 0;
|
|
7987
|
+
|
|
7988
|
+
const placement: CoalescingPlacement = { agent: '' };
|
|
7989
|
+
if (!silentHeartbeat) {
|
|
7990
|
+
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
7991
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
7992
|
+
} else {
|
|
7993
|
+
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
|
|
7994
|
+
}
|
|
7271
7995
|
|
|
7272
7996
|
if (event.triggerInference) {
|
|
7273
7997
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
@@ -7285,9 +8009,17 @@ export class AgentFramework {
|
|
|
7285
8009
|
// they must outrank ambient chatter in a batched wake's locus
|
|
7286
8010
|
// selection just like their channels/incoming counterparts.
|
|
7287
8011
|
addressed: isAddressedMessage(event.tags, event.origin),
|
|
8012
|
+
...(silentHeartbeat ? {
|
|
8013
|
+
suppressProse: true,
|
|
8014
|
+
ephemeralSystemPrompt:
|
|
8015
|
+
'[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
|
|
8016
|
+
'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
|
|
8017
|
+
} : {}),
|
|
8018
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
7288
8019
|
});
|
|
7289
8020
|
}
|
|
7290
8021
|
}
|
|
8022
|
+
return placement.agent ? placement : undefined;
|
|
7291
8023
|
}
|
|
7292
8024
|
|
|
7293
8025
|
/**
|
|
@@ -7445,7 +8177,9 @@ export class AgentFramework {
|
|
|
7445
8177
|
// route the resumed turn into the wrong channel.
|
|
7446
8178
|
const newestByKey = new Map<string, InferenceRequest>();
|
|
7447
8179
|
for (const request of parked) {
|
|
7448
|
-
|
|
8180
|
+
// A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
|
|
8181
|
+
// must not absorb an unrelated wake that would vanish with it.
|
|
8182
|
+
const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
|
|
7449
8183
|
const prev = newestByKey.get(key);
|
|
7450
8184
|
if (!prev || request.timestamp >= prev.timestamp) {
|
|
7451
8185
|
newestByKey.set(key, request);
|
|
@@ -7574,8 +8308,13 @@ export class AgentFramework {
|
|
|
7574
8308
|
if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
|
|
7575
8309
|
}
|
|
7576
8310
|
}
|
|
8311
|
+
// A silent tick never suppresses a genuine coalesced wake. If any
|
|
8312
|
+
// ordinary request shares this batch, the ordinary turn semantics win.
|
|
8313
|
+
const silentOnly = requests.every((r) => r.suppressProse === true);
|
|
7577
8314
|
await this.startAgentStream(agent, {
|
|
7578
8315
|
...trigger,
|
|
8316
|
+
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
|
|
8317
|
+
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
|
|
7579
8318
|
channelId: channelReq?.channelId,
|
|
7580
8319
|
addressed: addressedReq !== undefined,
|
|
7581
8320
|
// A context-budget restart continues the same logical turn: it keeps
|
|
@@ -8140,6 +8879,9 @@ export class AgentFramework {
|
|
|
8140
8879
|
turnToken: number,
|
|
8141
8880
|
ownsProviderGate: boolean,
|
|
8142
8881
|
): Promise<boolean> {
|
|
8882
|
+
const continuingTurn = trigger?.reason === 'context_budget_restart'
|
|
8883
|
+
|| trigger?.reason === 'tool_result_guard_retry';
|
|
8884
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
8143
8885
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
8144
8886
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
8145
8887
|
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
@@ -8156,7 +8898,7 @@ export class AgentFramework {
|
|
|
8156
8898
|
// products — undoing the turn must not destroy them.
|
|
8157
8899
|
if (
|
|
8158
8900
|
attempt === 0 &&
|
|
8159
|
-
|
|
8901
|
+
!continuingTurn &&
|
|
8160
8902
|
this.deferredMessages.length > 0
|
|
8161
8903
|
) {
|
|
8162
8904
|
{
|
|
@@ -8182,8 +8924,20 @@ export class AgentFramework {
|
|
|
8182
8924
|
}
|
|
8183
8925
|
}
|
|
8184
8926
|
|
|
8927
|
+
// RFC-006 §5: deferred batches for this agent render now, at the assembly
|
|
8928
|
+
// boundary — after the deferred flush (same window: turn alive, compile
|
|
8929
|
+
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
8930
|
+
// Not on a context-budget restart: that continues the same logical turn.
|
|
8931
|
+
if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
|
|
8932
|
+
try {
|
|
8933
|
+
await this.pushCoalescer.assemble(agent.name);
|
|
8934
|
+
} catch (err) {
|
|
8935
|
+
console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
|
|
8936
|
+
}
|
|
8937
|
+
}
|
|
8938
|
+
|
|
8185
8939
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
8186
|
-
if (attempt === 0) {
|
|
8940
|
+
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
8187
8941
|
this.recordTurnCheckpoint(agent.name);
|
|
8188
8942
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
8189
8943
|
}
|
|
@@ -8201,7 +8955,7 @@ export class AgentFramework {
|
|
|
8201
8955
|
// after the restart fell through to the hours-old defaultPublishChannel.
|
|
8202
8956
|
// Keep the turn's trigger channel across restarts; only real new turns
|
|
8203
8957
|
// reset it.
|
|
8204
|
-
if (
|
|
8958
|
+
if (!continuingTurn) {
|
|
8205
8959
|
if (trigger?.channelId) {
|
|
8206
8960
|
this.activeTriggerChannels.set(agent.name, trigger.channelId);
|
|
8207
8961
|
} else {
|
|
@@ -8221,12 +8975,12 @@ export class AgentFramework {
|
|
|
8221
8975
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
8222
8976
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
8223
8977
|
// KV stability).
|
|
8224
|
-
if (
|
|
8978
|
+
if (continuingTurn) {
|
|
8225
8979
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
8226
8980
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
8227
8981
|
}
|
|
8228
8982
|
|
|
8229
|
-
if (
|
|
8983
|
+
if (!continuingTurn) {
|
|
8230
8984
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
8231
8985
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
8232
8986
|
if (attempt === 0) {
|
|
@@ -8243,8 +8997,8 @@ export class AgentFramework {
|
|
|
8243
8997
|
this.turnProseDeliveries.delete(agent.name);
|
|
8244
8998
|
this.turnProseSuppressed.delete(agent.name);
|
|
8245
8999
|
this.proseHybridSuppressed.delete(agent.name);
|
|
8246
|
-
if (
|
|
8247
|
-
if (
|
|
9000
|
+
if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
|
|
9001
|
+
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
8248
9002
|
// Explicit and disabled prose routing have no inferred locus.
|
|
8249
9003
|
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
8250
9004
|
// prose target at all. Neither mode freezes or announces a locus.
|
|
@@ -8261,6 +9015,15 @@ export class AgentFramework {
|
|
|
8261
9015
|
}
|
|
8262
9016
|
}
|
|
8263
9017
|
|
|
9018
|
+
// A context-budget restart normally preserves the turn locus, but a
|
|
9019
|
+
// silent control-plane wake must remain locusless even if an explicit tool
|
|
9020
|
+
// opened/pinned a channel mid-turn.
|
|
9021
|
+
if (trigger?.suppressProse) {
|
|
9022
|
+
this.turnLocusPins.delete(agent.name);
|
|
9023
|
+
this.proseTargetPins.delete(agent.name);
|
|
9024
|
+
this.proseContinuations.delete(agent.name);
|
|
9025
|
+
}
|
|
9026
|
+
|
|
8264
9027
|
this.touchEphemeralRun(agent.name, true);
|
|
8265
9028
|
this.emitTrace({
|
|
8266
9029
|
type: 'inference:started',
|
|
@@ -8268,10 +9031,11 @@ export class AgentFramework {
|
|
|
8268
9031
|
// The turn-frozen locus was pinned just above (kept across a
|
|
8269
9032
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
8270
9033
|
channelId: this.turnLocusPins.get(agent.name),
|
|
9034
|
+
...(trigger?.suppressProse ? { silent: true } : {}),
|
|
8271
9035
|
});
|
|
8272
9036
|
// A budget restart continues the same logical inference window. The
|
|
8273
9037
|
// predecessor keeps EventGate liveness until its successor terminates.
|
|
8274
|
-
if (
|
|
9038
|
+
if (!continuingTurn) {
|
|
8275
9039
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
8276
9040
|
}
|
|
8277
9041
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
@@ -8286,18 +9050,14 @@ export class AgentFramework {
|
|
|
8286
9050
|
// idempotent per channel, and owns the 7s refresh); the catch below stops
|
|
8287
9051
|
// it on the no-driveStream failure paths (e.g. a compile refusal).
|
|
8288
9052
|
const earlyTypingChannel =
|
|
8289
|
-
|
|
9053
|
+
turnProseRouting === 'explicit'
|
|
8290
9054
|
? trigger?.channelId ?? null
|
|
8291
9055
|
: this.turnLocusPins.get(agent.name) ?? null;
|
|
8292
9056
|
if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
8293
9057
|
|
|
8294
9058
|
try {
|
|
8295
9059
|
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
8296
|
-
const
|
|
8297
|
-
const tools = allTools.filter((t) => agent.canUseTool(t.name));
|
|
8298
|
-
// Explicit-mode agents get the on-demand routing reference (teach-by-
|
|
8299
|
-
// bounce: the grammar is never injected, only served when asked).
|
|
8300
|
-
if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
|
|
9060
|
+
const tools = this.agentToolSurface(agent, requestSnapshot);
|
|
8301
9061
|
|
|
8302
9062
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
8303
9063
|
// Both produce ContextInjection[] that get merged before inference.
|
|
@@ -8336,6 +9096,15 @@ export class AgentFramework {
|
|
|
8336
9096
|
}
|
|
8337
9097
|
}
|
|
8338
9098
|
|
|
9099
|
+
if (trigger?.ephemeralSystemPrompt) {
|
|
9100
|
+
const silentInjection: ContextInjection = {
|
|
9101
|
+
namespace: 'framework:silent-heartbeat',
|
|
9102
|
+
position: 'system',
|
|
9103
|
+
content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
|
|
9104
|
+
};
|
|
9105
|
+
injections = injections ? [...injections, silentInjection] : [silentInjection];
|
|
9106
|
+
}
|
|
9107
|
+
|
|
8339
9108
|
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
8340
9109
|
// Do not create a provider stream after its generation lost ownership.
|
|
8341
9110
|
if (this.agents.get(agent.name) !== agent) {
|
|
@@ -8466,6 +9235,7 @@ export class AgentFramework {
|
|
|
8466
9235
|
drainKvSubmissionIds?: () => string[],
|
|
8467
9236
|
knownToolNames: ReadonlySet<string> = new Set(),
|
|
8468
9237
|
): Promise<void> {
|
|
9238
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
8469
9239
|
const startTime = Date.now();
|
|
8470
9240
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8471
9241
|
const myStreamId = agent.streamId;
|
|
@@ -8523,7 +9293,10 @@ export class AgentFramework {
|
|
|
8523
9293
|
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
8524
9294
|
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
8525
9295
|
// clears it, because the following prose is a reply to a new message.
|
|
8526
|
-
let turnSilenced =
|
|
9296
|
+
let turnSilenced = trigger?.suppressProse === true
|
|
9297
|
+
|| (trigger?.reason === 'tool_result_guard_retry'
|
|
9298
|
+
&& this.guardRetryTurnSilenced.get(agent.name) === true);
|
|
9299
|
+
this.guardRetryTurnSilenced.delete(agent.name);
|
|
8527
9300
|
|
|
8528
9301
|
// Live routing is only trusted when the membrane provides verbatim
|
|
8529
9302
|
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
@@ -8546,9 +9319,9 @@ export class AgentFramework {
|
|
|
8546
9319
|
// sent here", which is true regardless of where the reply goes.
|
|
8547
9320
|
// Heartbeat/no-trigger explicit turns show no indicator.
|
|
8548
9321
|
const typingChannel =
|
|
8549
|
-
|
|
9322
|
+
turnProseRouting === 'disabled'
|
|
8550
9323
|
? null
|
|
8551
|
-
:
|
|
9324
|
+
: turnProseRouting === 'explicit'
|
|
8552
9325
|
? trigger?.channelId ?? null
|
|
8553
9326
|
: resolveTurnLocus();
|
|
8554
9327
|
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
@@ -8566,6 +9339,9 @@ export class AgentFramework {
|
|
|
8566
9339
|
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
8567
9340
|
// finalized) instead of being appended to — see the 'retrying' case.
|
|
8568
9341
|
let outgoingInferenceId = newOutgoingInferenceId();
|
|
9342
|
+
// RFC-007: every inference id THIS stream mints, so the stream's end
|
|
9343
|
+
// aborts only its own open tool calls — never a successor's.
|
|
9344
|
+
const lifecycleInferenceIds = new Set<string>([outgoingInferenceId]);
|
|
8569
9345
|
let outgoingIndex = 0;
|
|
8570
9346
|
|
|
8571
9347
|
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
@@ -8586,10 +9362,10 @@ export class AgentFramework {
|
|
|
8586
9362
|
// turns until completion: otherwise speculative outgoing chunks could
|
|
8587
9363
|
// expose the wrapper before the fail-closed classifier runs.
|
|
8588
9364
|
const proseStream = this.channelRegistry &&
|
|
8589
|
-
|
|
9365
|
+
turnProseRouting !== 'disabled' &&
|
|
8590
9366
|
!agent.toolWrapperProseGuard
|
|
8591
9367
|
? new ProseStreamRouter({
|
|
8592
|
-
mode:
|
|
9368
|
+
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
8593
9369
|
initialTarget: typingChannel,
|
|
8594
9370
|
resolve: (spec) => {
|
|
8595
9371
|
const r = this.channelRegistry!.resolveProseTarget(spec);
|
|
@@ -8605,6 +9381,23 @@ export class AgentFramework {
|
|
|
8605
9381
|
}
|
|
8606
9382
|
};
|
|
8607
9383
|
|
|
9384
|
+
// Tool-result-guard publication boundary. While a SUBMITTED batch is
|
|
9385
|
+
// pending (or a guard recovery is running), a refusal can still abandon
|
|
9386
|
+
// this physical round, so its streamed text must not reach any surface
|
|
9387
|
+
// yet — neither the prose router nor the inference:tokens trace (TTS and
|
|
9388
|
+
// other trace consumers voice it). Hold both; release in order on a clean
|
|
9389
|
+
// round (tool-calls / non-guard completion), discard on a guard refusal.
|
|
9390
|
+
let guardHeld: Array<{ trace: Parameters<AgentFramework['emitTrace']>[0]; text?: string }> = [];
|
|
9391
|
+
const releaseGuardHeld = (): void => {
|
|
9392
|
+
const held = guardHeld;
|
|
9393
|
+
guardHeld = [];
|
|
9394
|
+
for (const item of held) {
|
|
9395
|
+
this.emitTrace(item.trace);
|
|
9396
|
+
if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text));
|
|
9397
|
+
}
|
|
9398
|
+
};
|
|
9399
|
+
const discardGuardHeld = (): void => { guardHeld = []; };
|
|
9400
|
+
|
|
8608
9401
|
const adoptInjectedRound = (): void => {
|
|
8609
9402
|
if (!this.midTurnInputSignals.has(agent.name)) return;
|
|
8610
9403
|
this.midTurnInputSignals.delete(agent.name);
|
|
@@ -8612,7 +9405,7 @@ export class AgentFramework {
|
|
|
8612
9405
|
// A new conversational message, not merely a tool result, means any
|
|
8613
9406
|
// earlier explicit delivery has completed its conversational job.
|
|
8614
9407
|
// Routing is NOT touched: the turn locus stays frozen.
|
|
8615
|
-
turnSilenced =
|
|
9408
|
+
turnSilenced = trigger?.suppressProse === true;
|
|
8616
9409
|
};
|
|
8617
9410
|
|
|
8618
9411
|
try {
|
|
@@ -8627,8 +9420,8 @@ export class AgentFramework {
|
|
|
8627
9420
|
}
|
|
8628
9421
|
this.touchEphemeralRun(agent.name, true);
|
|
8629
9422
|
switch (event.type) {
|
|
8630
|
-
case 'tokens':
|
|
8631
|
-
|
|
9423
|
+
case 'tokens': {
|
|
9424
|
+
const trace: Parameters<AgentFramework['emitTrace']>[0] = {
|
|
8632
9425
|
type: 'inference:tokens',
|
|
8633
9426
|
agentName: agent.name,
|
|
8634
9427
|
content: event.content,
|
|
@@ -8638,11 +9431,16 @@ export class AgentFramework {
|
|
|
8638
9431
|
// lets trace consumers tag every chunk with the channel this
|
|
8639
9432
|
// turn's prose is bound for, without re-deriving routing.
|
|
8640
9433
|
channelId: typingChannel ?? undefined,
|
|
8641
|
-
}
|
|
8642
|
-
|
|
8643
|
-
|
|
9434
|
+
};
|
|
9435
|
+
const text = event.meta.type === 'text' ? event.content : undefined;
|
|
9436
|
+
if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
|
|
9437
|
+
guardHeld.push({ trace, text });
|
|
9438
|
+
} else {
|
|
9439
|
+
this.emitTrace(trace);
|
|
9440
|
+
if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text));
|
|
8644
9441
|
}
|
|
8645
9442
|
break;
|
|
9443
|
+
}
|
|
8646
9444
|
|
|
8647
9445
|
case 'retrying': {
|
|
8648
9446
|
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
@@ -8663,7 +9461,9 @@ export class AgentFramework {
|
|
|
8663
9461
|
' — discarding the refused attempt',
|
|
8664
9462
|
);
|
|
8665
9463
|
outgoingInferenceId = newOutgoingInferenceId();
|
|
9464
|
+
lifecycleInferenceIds.add(outgoingInferenceId);
|
|
8666
9465
|
outgoingIndex = 0;
|
|
9466
|
+
discardGuardHeld();
|
|
8667
9467
|
proseStream?.reset();
|
|
8668
9468
|
this.emitTrace({
|
|
8669
9469
|
type: 'inference:tokens',
|
|
@@ -8690,6 +9490,10 @@ export class AgentFramework {
|
|
|
8690
9490
|
}
|
|
8691
9491
|
|
|
8692
9492
|
case 'tool-calls': {
|
|
9493
|
+
// A tool-call response is a clean physical round: admit its
|
|
9494
|
+
// preceding results before storing/dispatching this new round.
|
|
9495
|
+
agent.toolResultGuard.accept();
|
|
9496
|
+
releaseGuardHeld();
|
|
8693
9497
|
adoptInjectedRound();
|
|
8694
9498
|
hadToolCalls = true;
|
|
8695
9499
|
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
@@ -8761,7 +9565,14 @@ export class AgentFramework {
|
|
|
8761
9565
|
agent.enterWaitingForTools(event.calls, stream);
|
|
8762
9566
|
|
|
8763
9567
|
for (const call of event.calls) {
|
|
9568
|
+
// RFC-007: register at the one dispatch point for model-issued
|
|
9569
|
+
// calls, keyed by (agent, call id) with this inference's id;
|
|
9570
|
+
// `started` goes out once dispatch returns. Refusal sites inside
|
|
9571
|
+
// dispatch run synchronously and call refuse() first, so a call
|
|
9572
|
+
// the host refuses produces no events.
|
|
9573
|
+
this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
|
|
8764
9574
|
this.dispatchToolCall(agent.name, call);
|
|
9575
|
+
this.toolLifecycleEmitter?.open(agent.name, call.id);
|
|
8765
9576
|
}
|
|
8766
9577
|
|
|
8767
9578
|
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
@@ -8787,12 +9598,12 @@ export class AgentFramework {
|
|
|
8787
9598
|
liveProseRouting = true;
|
|
8788
9599
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
8789
9600
|
if (roundSegments.length > 0) {
|
|
8790
|
-
if (
|
|
9601
|
+
if (turnProseRouting === 'disabled') {
|
|
8791
9602
|
console.error(
|
|
8792
9603
|
`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
|
|
8793
9604
|
);
|
|
8794
9605
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
8795
|
-
} else if (
|
|
9606
|
+
} else if (turnProseRouting === 'hybrid') {
|
|
8796
9607
|
if (turnSilenced) {
|
|
8797
9608
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
8798
9609
|
} else if (!hasSameRoundPrivateThink) {
|
|
@@ -8803,7 +9614,7 @@ export class AgentFramework {
|
|
|
8803
9614
|
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
8804
9615
|
}
|
|
8805
9616
|
}
|
|
8806
|
-
} else if (
|
|
9617
|
+
} else if (turnProseRouting === 'explicit') {
|
|
8807
9618
|
// Explicit mode: every segment through the prose gateway.
|
|
8808
9619
|
// Silencing does not apply — unprefixed prose bounces and
|
|
8809
9620
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -8847,7 +9658,86 @@ export class AgentFramework {
|
|
|
8847
9658
|
case 'complete': {
|
|
8848
9659
|
adoptInjectedRound();
|
|
8849
9660
|
const durationMs = Date.now() - startTime;
|
|
8850
|
-
|
|
9661
|
+
let response = event.response;
|
|
9662
|
+
const guardCategory = (response.raw?.response as {
|
|
9663
|
+
stop_details?: { category?: string };
|
|
9664
|
+
} | undefined)?.stop_details?.category ?? 'unknown';
|
|
9665
|
+
// A staged batch the strategy omitted from the request cannot
|
|
9666
|
+
// have caused this refusal: settle it and fall through to the
|
|
9667
|
+
// ordinary refusal handling (rewind/reaction) below.
|
|
9668
|
+
if (response.stopReason === 'refusal') agent.toolResultGuard.settleUnsubmitted(guardCategory);
|
|
9669
|
+
const guardRefusal = response.stopReason === 'refusal'
|
|
9670
|
+
&& (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
|
|
9671
|
+
if (guardRefusal) {
|
|
9672
|
+
discardGuardHeld();
|
|
9673
|
+
const category = guardCategory;
|
|
9674
|
+
const withheld = agent.toolResultGuard.withhold(category);
|
|
9675
|
+
// Nothing from the abandoned physical attempt is published or
|
|
9676
|
+
// persisted as assistant speech, including a terminal refusal
|
|
9677
|
+
// after the single recovery retry.
|
|
9678
|
+
proseStream?.reset();
|
|
9679
|
+
if (withheld) {
|
|
9680
|
+
const usage = response.details?.usage ?? response.usage;
|
|
9681
|
+
const tokenUsage = usage ? {
|
|
9682
|
+
input: usage.inputTokens, output: usage.outputTokens,
|
|
9683
|
+
cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
|
|
9684
|
+
} : undefined;
|
|
9685
|
+
this.noteRefusal(agent.name, category, tokenUsage);
|
|
9686
|
+
// The abandoned stream was billed (membrane usage here is
|
|
9687
|
+
// cumulative across this physical tool loop): count it before
|
|
9688
|
+
// the retry opens a fresh stream with its own usage.
|
|
9689
|
+
const abandonedUsage = response.details?.usage;
|
|
9690
|
+
if (abandonedUsage) {
|
|
9691
|
+
this.usageTracker.onInferenceCompleted(agent.name, {
|
|
9692
|
+
inputTokens: abandonedUsage.inputTokens,
|
|
9693
|
+
outputTokens: abandonedUsage.outputTokens,
|
|
9694
|
+
cacheCreationTokens: abandonedUsage.cacheCreationTokens,
|
|
9695
|
+
cacheReadTokens: abandonedUsage.cacheReadTokens,
|
|
9696
|
+
}, abandonedUsage.estimatedCost
|
|
9697
|
+
? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
|
|
9698
|
+
: undefined);
|
|
9699
|
+
this.persistUsageState();
|
|
9700
|
+
}
|
|
9701
|
+
this.logInference({
|
|
9702
|
+
timestamp: startTime, agentName: agent.name, requestId,
|
|
9703
|
+
success: false, error: 'Tool output withheld by the guard',
|
|
9704
|
+
request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
|
|
9705
|
+
});
|
|
9706
|
+
console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
|
|
9707
|
+
this.emitTrace({
|
|
9708
|
+
type: 'inference:stream_restarted', agentName: agent.name,
|
|
9709
|
+
reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
|
|
9710
|
+
budget: agent.maxStreamTokens,
|
|
9711
|
+
});
|
|
9712
|
+
await turnSpeechChain;
|
|
9713
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
9714
|
+
generationLost = true;
|
|
9715
|
+
lifecyclePhase = 'aborted';
|
|
9716
|
+
return;
|
|
9717
|
+
}
|
|
9718
|
+
agent.reset();
|
|
9719
|
+
preserveEventGateForSuccessor = true;
|
|
9720
|
+
lifecyclePhase = 'aborted';
|
|
9721
|
+
// Carry same-turn explicit-send suppression into the retry:
|
|
9722
|
+
// a text-only recovery after a successful send must not post
|
|
9723
|
+
// a postscript to a message already delivered.
|
|
9724
|
+
if (turnSilenced) this.guardRetryTurnSilenced.set(agent.name, true);
|
|
9725
|
+
// Restart inference inside this logical turn. No tool is
|
|
9726
|
+
// executed again and no settle/checkpoint/locus reset occurs.
|
|
9727
|
+
await this.startAgentStream(agent, {
|
|
9728
|
+
...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
|
|
9729
|
+
source: trigger?.source ?? 'framework', timestamp: Date.now(),
|
|
9730
|
+
}, attempt);
|
|
9731
|
+
return;
|
|
9732
|
+
}
|
|
9733
|
+
const lastResult = response.content.reduce((last, block, index) =>
|
|
9734
|
+
block.type === 'tool_result' ? index : last, -1);
|
|
9735
|
+
response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
|
|
9736
|
+
agent.toolResultGuard.recovering = false;
|
|
9737
|
+
} else if (response.stopReason !== 'refusal') {
|
|
9738
|
+
agent.toolResultGuard.accept();
|
|
9739
|
+
}
|
|
9740
|
+
if (!guardRefusal) releaseGuardHeld();
|
|
8851
9741
|
|
|
8852
9742
|
// If the agent is still waiting_for_tools when 'complete' fires
|
|
8853
9743
|
// (shouldn't happen after incomplete-tool-call fix, but guard anyway),
|
|
@@ -8876,12 +9766,17 @@ export class AgentFramework {
|
|
|
8876
9766
|
// one door a giant blob still walks through).
|
|
8877
9767
|
const readyState = agent.state as AgentState;
|
|
8878
9768
|
if (readyState.status === 'ready') {
|
|
8879
|
-
const
|
|
9769
|
+
const cap = this.resolveToolResultInlineCap(agent).cap;
|
|
9770
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(
|
|
8880
9771
|
agent.name,
|
|
8881
9772
|
readyState.toolResults,
|
|
8882
|
-
|
|
9773
|
+
cap,
|
|
8883
9774
|
);
|
|
8884
|
-
agent.
|
|
9775
|
+
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
|
|
9776
|
+
this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
|
|
9777
|
+
// The response is already complete: this batch is never
|
|
9778
|
+
// submitted in this turn, so it cannot be refused. Admit it.
|
|
9779
|
+
agent.toolResultGuard.settleTurnEnded();
|
|
8885
9780
|
}
|
|
8886
9781
|
}
|
|
8887
9782
|
|
|
@@ -9097,7 +9992,7 @@ export class AgentFramework {
|
|
|
9097
9992
|
},
|
|
9098
9993
|
() => this.finishUnstick(agent.name, false, category),
|
|
9099
9994
|
);
|
|
9100
|
-
} else if (rh?.autoRewind) {
|
|
9995
|
+
} else if (rh?.autoRewind && !guardRefusal) {
|
|
9101
9996
|
const cap = Math.max(1, rh.maxRewinds ?? 3);
|
|
9102
9997
|
const used = this.refusalRewinds.get(agent.name) ?? 0;
|
|
9103
9998
|
doRewind(
|
|
@@ -9145,7 +10040,7 @@ export class AgentFramework {
|
|
|
9145
10040
|
}
|
|
9146
10041
|
|
|
9147
10042
|
// Dispatch speech (and thoughts if any)
|
|
9148
|
-
if (speechContent.length > 0 || thoughts.length > 0) {
|
|
10043
|
+
if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
|
|
9149
10044
|
const speechContext: SpeechContext = {
|
|
9150
10045
|
turnComplete: true,
|
|
9151
10046
|
trigger: trigger ?? {
|
|
@@ -9176,10 +10071,17 @@ export class AgentFramework {
|
|
|
9176
10071
|
.join('\n')
|
|
9177
10072
|
.trim();
|
|
9178
10073
|
if (speechText) {
|
|
9179
|
-
if (
|
|
10074
|
+
if (turnProseRouting === 'disabled') {
|
|
9180
10075
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
9181
10076
|
this.recordProseSuppression(agent.name, 1);
|
|
9182
|
-
} else if (
|
|
10077
|
+
} else if (turnSilenced && turnProseRouting !== 'explicit') {
|
|
10078
|
+
// Only reachable on a guard recovery that carried the
|
|
10079
|
+
// same-turn send suppression, or a suppressProse trigger
|
|
10080
|
+
// (a fresh text-only turn starts unsilenced). Explicit
|
|
10081
|
+
// mode is exempt, as mid-turn.
|
|
10082
|
+
console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
|
|
10083
|
+
this.recordProseSuppression(agent.name, 1);
|
|
10084
|
+
} else if (turnProseRouting === 'hybrid') {
|
|
9183
10085
|
const locus = resolveTurnLocus();
|
|
9184
10086
|
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
9185
10087
|
try {
|
|
@@ -9187,7 +10089,7 @@ export class AgentFramework {
|
|
|
9187
10089
|
} catch (err) {
|
|
9188
10090
|
console.error('text-only hybrid prose delivery failed:', err);
|
|
9189
10091
|
}
|
|
9190
|
-
} else if (
|
|
10092
|
+
} else if (turnProseRouting === 'explicit') {
|
|
9191
10093
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
9192
10094
|
try {
|
|
9193
10095
|
await this.deliverProse(agent, speechText);
|
|
@@ -9243,14 +10145,14 @@ export class AgentFramework {
|
|
|
9243
10145
|
// the chain may still be flushing earlier rounds' posts.
|
|
9244
10146
|
await turnSpeechChain;
|
|
9245
10147
|
|
|
9246
|
-
if (
|
|
10148
|
+
if (turnProseRouting === 'disabled') {
|
|
9247
10149
|
if (segments.length > 0) {
|
|
9248
10150
|
console.error(
|
|
9249
10151
|
`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
|
|
9250
10152
|
);
|
|
9251
10153
|
this.recordProseSuppression(agent.name, segments.length);
|
|
9252
10154
|
}
|
|
9253
|
-
} else if (
|
|
10155
|
+
} else if (turnProseRouting === 'hybrid') {
|
|
9254
10156
|
if (silenced && segments.length > 0) {
|
|
9255
10157
|
this.recordProseSuppression(agent.name, segments.length);
|
|
9256
10158
|
} else if (segments.length > 0) {
|
|
@@ -9263,7 +10165,7 @@ export class AgentFramework {
|
|
|
9263
10165
|
}
|
|
9264
10166
|
}
|
|
9265
10167
|
}
|
|
9266
|
-
} else if (
|
|
10168
|
+
} else if (turnProseRouting === 'explicit') {
|
|
9267
10169
|
if (segments.length > 0) {
|
|
9268
10170
|
console.error(
|
|
9269
10171
|
`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
|
|
@@ -9316,7 +10218,7 @@ export class AgentFramework {
|
|
|
9316
10218
|
// awaited before trailing dispatch, and trailing/text-only
|
|
9317
10219
|
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
9318
10220
|
// envelopes acknowledge themselves through the prose gateway.
|
|
9319
|
-
if (
|
|
10221
|
+
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
9320
10222
|
this.appendProseDeliveryReceipt(agent);
|
|
9321
10223
|
}
|
|
9322
10224
|
|
|
@@ -9485,7 +10387,7 @@ export class AgentFramework {
|
|
|
9485
10387
|
// deliveries map persists (cleared only at fresh turn
|
|
9486
10388
|
// start) and the continuation stream's end writes ONE
|
|
9487
10389
|
// receipt for the whole turn.
|
|
9488
|
-
if (cancelKind === 'turn_ended' &&
|
|
10390
|
+
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
9489
10391
|
await turnSpeechChain;
|
|
9490
10392
|
this.appendProseDeliveryReceipt(agent);
|
|
9491
10393
|
}
|
|
@@ -9705,6 +10607,13 @@ export class AgentFramework {
|
|
|
9705
10607
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
9706
10608
|
// crash emits nothing — that is the documented best-effort limit, and
|
|
9707
10609
|
// why consumers keep their safety timeout.
|
|
10610
|
+
//
|
|
10611
|
+
// RFC-007 §7.3: a tool call still open when its turn's stream ends was
|
|
10612
|
+
// cancelled before a result — its terminal is `aborted`. (A normal turn
|
|
10613
|
+
// has none: the stream waits for every result before continuing.)
|
|
10614
|
+
// Scoped to this stream's own inference ids: a budget-restart successor
|
|
10615
|
+
// may already be running for the same agent.
|
|
10616
|
+
this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
|
|
9708
10617
|
this.hookOrchestrator?.emitLifecycle({
|
|
9709
10618
|
inferenceId: outgoingInferenceId,
|
|
9710
10619
|
conversationId: agent.name,
|
|
@@ -9719,6 +10628,22 @@ export class AgentFramework {
|
|
|
9719
10628
|
// (typing still stops, compression still runs — matching the observed
|
|
9720
10629
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
9721
10630
|
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
10631
|
+
// A frame that ends without handing off to a successor (abort,
|
|
10632
|
+
// exhausted errors) must not leave its batch pending: a later turn
|
|
10633
|
+
// would resubmit the originals and claim its own refusal. Successor
|
|
10634
|
+
// frames (error-policy retry, budget/guard restart) bump streamId or
|
|
10635
|
+
// set preserveEventGateForSuccessor and keep the batch.
|
|
10636
|
+
if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
|
|
10637
|
+
// abandon() clears the batch before its audit append, so a failed
|
|
10638
|
+
// write cannot re-arm it; it must not abort the rest of teardown
|
|
10639
|
+
// (gate release, turn token, provider admission) either.
|
|
10640
|
+
try {
|
|
10641
|
+
agent.toolResultGuard.abandon('aborted');
|
|
10642
|
+
} catch (error) {
|
|
10643
|
+
console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
|
|
10644
|
+
}
|
|
10645
|
+
}
|
|
10646
|
+
agent.toolResultGuard.recovering = false;
|
|
9722
10647
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
9723
10648
|
}
|
|
9724
10649
|
if (!generationLost && ownsPhysicalStream) {
|
|
@@ -10061,6 +10986,7 @@ export class AgentFramework {
|
|
|
10061
10986
|
const startTime = Date.now();
|
|
10062
10987
|
this.runCodeExecution(agentName, call)
|
|
10063
10988
|
.catch((error): ToolResult => {
|
|
10989
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
10064
10990
|
// runCodeExecution is designed not to reject; this is the last-resort
|
|
10065
10991
|
// guard so a bug here can never strand the agent in waiting_for_tools.
|
|
10066
10992
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -10110,6 +11036,7 @@ export class AgentFramework {
|
|
|
10110
11036
|
background?: unknown;
|
|
10111
11037
|
action?: unknown;
|
|
10112
11038
|
script_id?: unknown;
|
|
11039
|
+
time_limit_ms?: unknown;
|
|
10113
11040
|
};
|
|
10114
11041
|
|
|
10115
11042
|
// Management surface: the agent's own daemon fleet is inspectable and
|
|
@@ -10158,6 +11085,18 @@ export class AgentFramework {
|
|
|
10158
11085
|
};
|
|
10159
11086
|
}
|
|
10160
11087
|
|
|
11088
|
+
// A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
|
|
11089
|
+
if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
|
|
11090
|
+
return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
|
|
11091
|
+
}
|
|
11092
|
+
const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
|
|
11093
|
+
const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
|
|
11094
|
+
const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
|
|
11095
|
+
const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
|
|
11096
|
+
const capNote = requestedMs !== undefined && requestedMs > ceilingMs
|
|
11097
|
+
? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
|
|
11098
|
+
: undefined;
|
|
11099
|
+
|
|
10161
11100
|
const agent = this.agents.get(agentName);
|
|
10162
11101
|
const surface = agent
|
|
10163
11102
|
? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
|
|
@@ -10167,17 +11106,26 @@ export class AgentFramework {
|
|
|
10167
11106
|
);
|
|
10168
11107
|
|
|
10169
11108
|
if (input.background === true) {
|
|
10170
|
-
|
|
11109
|
+
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
|
|
11110
|
+
if (capNote && started.success && started.data && typeof started.data === 'object') {
|
|
11111
|
+
(started.data as Record<string, unknown>).time_limit_note = capNote;
|
|
11112
|
+
}
|
|
11113
|
+
return started;
|
|
10171
11114
|
}
|
|
10172
11115
|
|
|
10173
11116
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
10174
11117
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
10175
|
-
const exec = await runner.exec(input.code, injected);
|
|
11118
|
+
const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
|
|
10176
11119
|
const endTurn = this.scriptDeferredEndTurn.delete(agentName);
|
|
10177
11120
|
|
|
10178
11121
|
return {
|
|
10179
11122
|
success: true,
|
|
10180
|
-
data: {
|
|
11123
|
+
data: {
|
|
11124
|
+
stdout: exec.stdout,
|
|
11125
|
+
stderr: exec.stderr,
|
|
11126
|
+
return_code: exec.returnCode,
|
|
11127
|
+
...(capNote ? { time_limit_note: capNote } : {}),
|
|
11128
|
+
},
|
|
10181
11129
|
isError: false,
|
|
10182
11130
|
...(endTurn ? { endTurn: true } : {}),
|
|
10183
11131
|
};
|
|
@@ -10199,6 +11147,7 @@ export class AgentFramework {
|
|
|
10199
11147
|
agentName: string,
|
|
10200
11148
|
code: string,
|
|
10201
11149
|
injected: import('./code-execution/py-runner.js').InjectedTool[],
|
|
11150
|
+
timeLimitMs?: number,
|
|
10202
11151
|
): ToolResult {
|
|
10203
11152
|
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
10204
11153
|
// would outlive its owner and its wake would land in the primary
|
|
@@ -10224,7 +11173,8 @@ export class AgentFramework {
|
|
|
10224
11173
|
}
|
|
10225
11174
|
|
|
10226
11175
|
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
10227
|
-
|
|
11176
|
+
// The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
|
|
11177
|
+
const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
10228
11178
|
|
|
10229
11179
|
// Journal: a file under the agent's first read-write workspace mount so
|
|
10230
11180
|
// their existing read/grep/shell tools work on it. Python appends
|
|
@@ -10290,6 +11240,7 @@ export class AgentFramework {
|
|
|
10290
11240
|
script_id: scriptId,
|
|
10291
11241
|
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
10292
11242
|
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
11243
|
+
lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
|
|
10293
11244
|
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
10294
11245
|
},
|
|
10295
11246
|
};
|
|
@@ -10926,17 +11877,91 @@ export class AgentFramework {
|
|
|
10926
11877
|
this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
|
|
10927
11878
|
}
|
|
10928
11879
|
|
|
11880
|
+
/** Forget one server's RFC-008 class declarations (see the close handler). */
|
|
11881
|
+
private dropMcplToolClasses(serverId: string): void {
|
|
11882
|
+
// Tolerate partially-constructed frameworks (tests build them with
|
|
11883
|
+
// Object.create): nothing cached means nothing to forget.
|
|
11884
|
+
if (!this.mcplToolClasses?.size) return;
|
|
11885
|
+
const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
|
|
11886
|
+
for (const name of [...this.mcplToolClasses.keys()]) {
|
|
11887
|
+
if (name.startsWith(`${prefix}--`)) this.mcplToolClasses.delete(name);
|
|
11888
|
+
}
|
|
11889
|
+
}
|
|
11890
|
+
|
|
11891
|
+
/**
|
|
11892
|
+
* RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
|
|
11893
|
+
* its effective class, and for MCPL tools the providing connection and
|
|
11894
|
+
* the server's own tool name. Host knowledge (built-in table, embedding
|
|
11895
|
+
* host's table) applies only to tools the host implements; an MCPL tool's
|
|
11896
|
+
* class comes from an operator override or its server's declaration.
|
|
11897
|
+
*/
|
|
11898
|
+
private describeToolForLifecycle(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string } {
|
|
11899
|
+
const mcpl = this.resolveMcplTool(tool);
|
|
11900
|
+
const { classes } = this.effectiveToolClass(tool, mcpl !== null);
|
|
11901
|
+
if (mcpl) return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
|
|
11902
|
+
return { class: classes };
|
|
11903
|
+
}
|
|
11904
|
+
|
|
10929
11905
|
/**
|
|
10930
|
-
*
|
|
10931
|
-
*
|
|
11906
|
+
* RFC-008 §5.1: a tool's effective class and the source that decided it.
|
|
11907
|
+
* Operator overrides first; then host knowledge, for tools the host
|
|
11908
|
+
* implements (never for MCPL tools); then an MCPL tool's own declaration.
|
|
11909
|
+
* Tolerates partially-constructed frameworks (tests use Object.create).
|
|
11910
|
+
*/
|
|
11911
|
+
private effectiveToolClass(tool: string, isMcpl: boolean): EffectiveToolClass {
|
|
11912
|
+
return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
|
|
11913
|
+
overrides: this.toolClassOverrides ?? [],
|
|
11914
|
+
host: isMcpl ? [] : (this.hostToolClasses ?? []),
|
|
11915
|
+
});
|
|
11916
|
+
}
|
|
11917
|
+
|
|
11918
|
+
/**
|
|
11919
|
+
* RFC-008 §6: tools with their effective class and where that class came
|
|
11920
|
+
* from (`override`, `host`, `server`, or `none` for an unclassed tool,
|
|
11921
|
+
* which policy treats as most restrictive). For operators: a surprising
|
|
11922
|
+
* class should be visible before it matters.
|
|
11923
|
+
*
|
|
11924
|
+
* With `agentName`, exactly the tools that agent is shown. Without, every
|
|
11925
|
+
* tool the framework offers to anyone: the shared board plus each agent's
|
|
11926
|
+
* own (the subconscious's surface, `prose_help`), each listed once.
|
|
11927
|
+
*/
|
|
11928
|
+
listToolClasses(agentName?: string): Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> {
|
|
11929
|
+
let tools: import('./types/index.js').ToolDefinition[];
|
|
11930
|
+
if (agentName !== undefined) {
|
|
11931
|
+
const agent = this.agents.get(agentName);
|
|
11932
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
11933
|
+
tools = this.agentToolSurface(agent);
|
|
11934
|
+
} else {
|
|
11935
|
+
tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
|
|
11936
|
+
}
|
|
11937
|
+
const seen = new Set<string>();
|
|
11938
|
+
const listed: Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> = [];
|
|
11939
|
+
for (const t of tools) {
|
|
11940
|
+
if (seen.has(t.name)) continue;
|
|
11941
|
+
seen.add(t.name);
|
|
11942
|
+
const mcpl = this.resolveMcplTool(t.name);
|
|
11943
|
+
const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
|
|
11944
|
+
listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
|
|
11945
|
+
}
|
|
11946
|
+
return listed;
|
|
11947
|
+
}
|
|
11948
|
+
|
|
11949
|
+
/**
|
|
11950
|
+
* Find the MCPL server for a tool: [serverId, prefix], or null.
|
|
11951
|
+
* Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
|
|
11952
|
+
* both. The server whose tools/list produced the name decides; a name no
|
|
11953
|
+
* live server listed (stale, or invented by the model) goes to the longest
|
|
11954
|
+
* matching prefix. Never prefix-map insertion order.
|
|
10932
11955
|
*/
|
|
10933
11956
|
private resolveMcplTool(toolName: string): [string, string] | null {
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
11957
|
+
const listedBy = this.mcplToolServers?.get(toolName);
|
|
11958
|
+
let best: [string, string] | null = null;
|
|
11959
|
+
for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
|
|
11960
|
+
if (!toolName.startsWith(prefix + '--')) continue;
|
|
11961
|
+
if (serverId === listedBy) return [serverId, prefix];
|
|
11962
|
+
if (!best || prefix.length > best[1].length) best = [serverId, prefix];
|
|
10938
11963
|
}
|
|
10939
|
-
return
|
|
11964
|
+
return best;
|
|
10940
11965
|
}
|
|
10941
11966
|
|
|
10942
11967
|
private dispatchToolCall(agentName: string, call: ToolCall): void {
|
|
@@ -11130,6 +12155,8 @@ export class AgentFramework {
|
|
|
11130
12155
|
})
|
|
11131
12156
|
.catch((error) => {
|
|
11132
12157
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
12158
|
+
// RFC-007: the module threw — the host could not obtain a result.
|
|
12159
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
11133
12160
|
this.emitTrace({
|
|
11134
12161
|
type: 'tool:failed',
|
|
11135
12162
|
module: moduleName,
|
|
@@ -11174,6 +12201,14 @@ export class AgentFramework {
|
|
|
11174
12201
|
* minting a second, independently replayable one.
|
|
11175
12202
|
*/
|
|
11176
12203
|
deferredWriteId?: string;
|
|
12204
|
+
/** Out-param: where the message landed (RFC-006 needs to find it again). */
|
|
12205
|
+
placement?: { agent?: string; messageId?: MessageId; deferredId?: string };
|
|
12206
|
+
/**
|
|
12207
|
+
* RFC-006 assembly: the named agent's turn is alive but its compile has
|
|
12208
|
+
* not run yet (the same window the turn-start deferred flush writes
|
|
12209
|
+
* in). A message for THAT agent is stored directly instead of deferred.
|
|
12210
|
+
*/
|
|
12211
|
+
bypassDeferralFor?: string;
|
|
11177
12212
|
}
|
|
11178
12213
|
): MessageId {
|
|
11179
12214
|
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
@@ -11223,6 +12258,7 @@ export class AgentFramework {
|
|
|
11223
12258
|
: this.pendingAssistantBlocks.size > 0;
|
|
11224
12259
|
if (
|
|
11225
12260
|
!hasToolResult &&
|
|
12261
|
+
opts?.bypassDeferralFor !== agent.name &&
|
|
11226
12262
|
(this.quiesced ||
|
|
11227
12263
|
midToolCycle ||
|
|
11228
12264
|
this.activeTurnTokens.has(agent.name) ||
|
|
@@ -11239,14 +12275,17 @@ export class AgentFramework {
|
|
|
11239
12275
|
}
|
|
11240
12276
|
this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
|
|
11241
12277
|
if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
|
|
12278
|
+
if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.deferredId = id; }
|
|
11242
12279
|
return '' as MessageId; // Deferred — flushed at the target's next boundary
|
|
11243
12280
|
}
|
|
11244
12281
|
|
|
11245
|
-
|
|
12282
|
+
const stored = agent.getContextManager().addMessage(
|
|
11246
12283
|
participant,
|
|
11247
12284
|
content,
|
|
11248
12285
|
opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata,
|
|
11249
12286
|
);
|
|
12287
|
+
if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
|
|
12288
|
+
return stored;
|
|
11250
12289
|
}
|
|
11251
12290
|
|
|
11252
12291
|
/**
|
|
@@ -11784,6 +12823,11 @@ export class AgentFramework {
|
|
|
11784
12823
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
11785
12824
|
this.featureSetManager = new FeatureSetManager();
|
|
11786
12825
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
12826
|
+
this.toolLifecycleEmitter = new ToolLifecycleEmitter({
|
|
12827
|
+
observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
|
|
12828
|
+
configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
|
|
12829
|
+
describe: (tool) => this.describeToolForLifecycle(tool),
|
|
12830
|
+
});
|
|
11787
12831
|
|
|
11788
12832
|
// Build prefix map and store configs for tool routing
|
|
11789
12833
|
for (const config of serverConfigs) {
|
|
@@ -11798,11 +12842,13 @@ export class AgentFramework {
|
|
|
11798
12842
|
?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
|
|
11799
12843
|
|
|
11800
12844
|
// Push events handler (Step 6)
|
|
12845
|
+
this.initializePushCoalescer();
|
|
11801
12846
|
this.pushHandler = new PushHandler(
|
|
11802
12847
|
this.featureSetManager,
|
|
11803
12848
|
(event) => this.pushEvent(event as unknown as ProcessEvent),
|
|
11804
12849
|
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
11805
12850
|
triggerFilter,
|
|
12851
|
+
(serverId, params, event) => this.handleCoalescedPush(serverId, params, event),
|
|
11806
12852
|
);
|
|
11807
12853
|
|
|
11808
12854
|
// Server-initiated inference router (Step 6)
|
|
@@ -11832,6 +12878,8 @@ export class AgentFramework {
|
|
|
11832
12878
|
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
11833
12879
|
{
|
|
11834
12880
|
store: this.store,
|
|
12881
|
+
handleCoalescedIncoming: (serverId, message, event) =>
|
|
12882
|
+
this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent),
|
|
11835
12883
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
11836
12884
|
const server = this.mcplServerRegistry!.getServer(serverId);
|
|
11837
12885
|
if (server) {
|
|
@@ -11906,11 +12954,17 @@ export class AgentFramework {
|
|
|
11906
12954
|
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
11907
12955
|
this.mcplHostCapabilities = {
|
|
11908
12956
|
version: '0.5',
|
|
12957
|
+
// RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
|
|
12958
|
+
// channel-scoped pushes, one-hour retry window.
|
|
12959
|
+
eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
|
|
11909
12960
|
pushEvents: true,
|
|
11910
12961
|
contextHooks: {
|
|
11911
12962
|
beforeInference: true,
|
|
11912
12963
|
},
|
|
11913
12964
|
inferenceLifecycle: true,
|
|
12965
|
+
// RFC-007: tools/lifecycle (metadata, and requested argument fields
|
|
12966
|
+
// under their own grant) plus the tools/observe filter.
|
|
12967
|
+
toolLifecycle: { observe: true, inputs: true },
|
|
11914
12968
|
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
11915
12969
|
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
11916
12970
|
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
@@ -12792,6 +13846,20 @@ export class AgentFramework {
|
|
|
12792
13846
|
deniedCapabilities: string[];
|
|
12793
13847
|
/** Host-owned authority; deliberately separate from the portable grant. */
|
|
12794
13848
|
allowHostCommands: boolean;
|
|
13849
|
+
/**
|
|
13850
|
+
* RFC-007 §10: the server's current tools/observe filter — its own
|
|
13851
|
+
* statement of which calls and argument fields it uses, shown next to
|
|
13852
|
+
* the grant so an operator can narrow toolLifecycle.inputs to match. It
|
|
13853
|
+
* confers nothing. Null = no filter (granted calls, metadata only).
|
|
13854
|
+
*/
|
|
13855
|
+
toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
|
|
13856
|
+
/**
|
|
13857
|
+
* RFC-008 §6: each of this server's tools with its effective class and
|
|
13858
|
+
* the source that decided it — `override` (operator), `server` (its own
|
|
13859
|
+
* `_meta["mcpl/class"]`), or `none` (unclassed: observers never see its
|
|
13860
|
+
* arguments). Shown next to the grant so a surprising class is visible.
|
|
13861
|
+
*/
|
|
13862
|
+
toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
|
|
12795
13863
|
/**
|
|
12796
13864
|
* Per-transport §17 facts about the last manifest this host fetched and
|
|
12797
13865
|
* acted on. The revision is server-authored and equality-only; these are
|
|
@@ -12810,6 +13878,8 @@ export class AgentFramework {
|
|
|
12810
13878
|
toolPrefix: string; toolCount: number; policyEstablished: boolean;
|
|
12811
13879
|
effectiveGrant: string[]; maskedCapabilities: string[];
|
|
12812
13880
|
deniedCapabilities: string[]; allowHostCommands: boolean;
|
|
13881
|
+
toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
|
|
13882
|
+
toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
|
|
12813
13883
|
manifestState: {
|
|
12814
13884
|
lastValidatedRevision: string | null;
|
|
12815
13885
|
lastFetchedAt: number | null;
|
|
@@ -12821,17 +13891,28 @@ export class AgentFramework {
|
|
|
12821
13891
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
12822
13892
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
12823
13893
|
const connected = connection?.isConnected ?? false;
|
|
13894
|
+
// Attribute through the dispatch resolver: a bare prefix match would
|
|
13895
|
+
// also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
|
|
13896
|
+
const ownTools = (this.mcplTools ?? []).flatMap((t) => {
|
|
13897
|
+
const hit = this.resolveMcplTool(t.name);
|
|
13898
|
+
return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
|
|
13899
|
+
});
|
|
12824
13900
|
result.push({
|
|
12825
13901
|
id,
|
|
12826
13902
|
connected,
|
|
12827
13903
|
retrying: !connected && (connection?.willReconnect ?? false),
|
|
12828
13904
|
toolPrefix: prefix,
|
|
12829
|
-
toolCount:
|
|
13905
|
+
toolCount: ownTools.length,
|
|
12830
13906
|
policyEstablished: connection?.policyEstablished ?? false,
|
|
12831
13907
|
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
12832
13908
|
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
12833
13909
|
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
12834
13910
|
allowHostCommands: config.allowHostCommands === true,
|
|
13911
|
+
toolObserveFilter: connection?.toolObserveFilter ?? null,
|
|
13912
|
+
toolClasses: ownTools.map(({ tool, serverTool }) => {
|
|
13913
|
+
const { classes, source } = this.effectiveToolClass(tool, true);
|
|
13914
|
+
return { tool, serverTool, class: classes, source };
|
|
13915
|
+
}),
|
|
12835
13916
|
manifestState: connection
|
|
12836
13917
|
? { ...connection.manifestState }
|
|
12837
13918
|
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
@@ -12870,7 +13951,7 @@ export class AgentFramework {
|
|
|
12870
13951
|
) => {
|
|
12871
13952
|
const barrier = this.discordAwarenessBarrier;
|
|
12872
13953
|
if (barrier) await barrier.promise;
|
|
12873
|
-
this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
|
|
13954
|
+
await this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
|
|
12874
13955
|
});
|
|
12875
13956
|
|
|
12876
13957
|
// Handle server-initiated inference requests (Step 6)
|
|
@@ -12878,6 +13959,11 @@ export class AgentFramework {
|
|
|
12878
13959
|
params: McplInferenceRequestParams,
|
|
12879
13960
|
responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
12880
13961
|
) => {
|
|
13962
|
+
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
|
|
13963
|
+
if (this.pushCoalescer?.isRendering(connection.id)) {
|
|
13964
|
+
responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
|
|
13965
|
+
return;
|
|
13966
|
+
}
|
|
12881
13967
|
if (this.inferenceRouter && responder) {
|
|
12882
13968
|
const barrier = this.discordAwarenessBarrier;
|
|
12883
13969
|
if (barrier) await barrier.promise;
|
|
@@ -12928,6 +14014,23 @@ export class AgentFramework {
|
|
|
12928
14014
|
});
|
|
12929
14015
|
});
|
|
12930
14016
|
|
|
14017
|
+
// RFC-007 §6: the server's filter over tools/lifecycle. Admission already
|
|
14018
|
+
// required toolLifecycle.observe (-32002 otherwise). Validation failures
|
|
14019
|
+
// leave the previous filter in force (§6.6).
|
|
14020
|
+
connection.on('tools-observe', (
|
|
14021
|
+
params: unknown,
|
|
14022
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
|
|
14023
|
+
) => {
|
|
14024
|
+
const parsed = parseToolObserveParams(params);
|
|
14025
|
+
if (!parsed.ok) {
|
|
14026
|
+
if (responder?.respondError) responder.respondError(-32602, parsed.message, parsed.data);
|
|
14027
|
+
else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
|
|
14028
|
+
return;
|
|
14029
|
+
}
|
|
14030
|
+
connection.toolObserveFilter = parsed.rules;
|
|
14031
|
+
responder?.respond({});
|
|
14032
|
+
});
|
|
14033
|
+
|
|
12931
14034
|
connection.on('model-info', (
|
|
12932
14035
|
_params: unknown,
|
|
12933
14036
|
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
|
|
@@ -12951,7 +14054,7 @@ export class AgentFramework {
|
|
|
12951
14054
|
) => {
|
|
12952
14055
|
const barrier = this.discordAwarenessBarrier;
|
|
12953
14056
|
if (barrier) await barrier.promise;
|
|
12954
|
-
this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
|
|
14057
|
+
await this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
|
|
12955
14058
|
});
|
|
12956
14059
|
|
|
12957
14060
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
@@ -13114,6 +14217,10 @@ export class AgentFramework {
|
|
|
13114
14217
|
// disconnectMcplServer, which deletes the trees explicitly.
|
|
13115
14218
|
connection.on('close', (code?: number | null, signal?: string | null) => {
|
|
13116
14219
|
this.featureSetManager?.removeServer(connection.id);
|
|
14220
|
+
// RFC-008: a closed provider's class hints are stale — on reconnect it
|
|
14221
|
+
// may class its tools differently. Until the next tools/list its tools
|
|
14222
|
+
// are unclassed, the restrictive answer (RFC-007 §4.3).
|
|
14223
|
+
this.dropMcplToolClasses(connection.id);
|
|
13117
14224
|
this.emitTrace({
|
|
13118
14225
|
type: 'mcpl:server-closed',
|
|
13119
14226
|
serverId: connection.id,
|
|
@@ -13134,6 +14241,8 @@ export class AgentFramework {
|
|
|
13134
14241
|
|
|
13135
14242
|
const tools: import('./types/index.js').ToolDefinition[] = [];
|
|
13136
14243
|
const toolFeatureSets = new Map<string, string>();
|
|
14244
|
+
const toolClasses = new Map<string, ToolClass[]>();
|
|
14245
|
+
const toolServers = new Map<string, string>();
|
|
13137
14246
|
|
|
13138
14247
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
13139
14248
|
const config = this.mcplServerConfigs.get(server.id);
|
|
@@ -13143,10 +14252,15 @@ export class AgentFramework {
|
|
|
13143
14252
|
for (const tool of result.tools) {
|
|
13144
14253
|
if (!isToolAllowed(tool.name, config)) continue;
|
|
13145
14254
|
const namespacedName = `${prefix}--${tool.name}`;
|
|
14255
|
+
toolServers.set(namespacedName, server.id);
|
|
13146
14256
|
const attributedTool = tool as typeof tool & {
|
|
13147
14257
|
featureSet?: unknown;
|
|
13148
|
-
_meta?: { featureSet?: unknown };
|
|
14258
|
+
_meta?: { featureSet?: unknown; [key: string]: unknown };
|
|
13149
14259
|
};
|
|
14260
|
+
// RFC-008: the server's class hint for this tool. Never shown to
|
|
14261
|
+
// the model (the definition below is unchanged by it).
|
|
14262
|
+
const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
|
|
14263
|
+
if (declaredClasses) toolClasses.set(namespacedName, declaredClasses);
|
|
13150
14264
|
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
13151
14265
|
? attributedTool.featureSet
|
|
13152
14266
|
: typeof attributedTool._meta?.featureSet === 'string'
|
|
@@ -13170,6 +14284,8 @@ export class AgentFramework {
|
|
|
13170
14284
|
|
|
13171
14285
|
this.mcplTools = tools;
|
|
13172
14286
|
this.mcplToolFeatureSets = toolFeatureSets;
|
|
14287
|
+
this.mcplToolClasses = toolClasses;
|
|
14288
|
+
this.mcplToolServers = toolServers;
|
|
13173
14289
|
}
|
|
13174
14290
|
|
|
13175
14291
|
/**
|
|
@@ -13235,6 +14351,8 @@ export class AgentFramework {
|
|
|
13235
14351
|
const server = this.mcplServerRegistry!.getServer(serverId);
|
|
13236
14352
|
|
|
13237
14353
|
if (!server) {
|
|
14354
|
+
// RFC-007: refused before executing — no lifecycle events.
|
|
14355
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
13238
14356
|
this.pushEvent({
|
|
13239
14357
|
type: 'tool-result',
|
|
13240
14358
|
callId: call.id,
|
|
@@ -13247,6 +14365,7 @@ export class AgentFramework {
|
|
|
13247
14365
|
|
|
13248
14366
|
const config = this.mcplServerConfigs.get(serverId);
|
|
13249
14367
|
if (!isToolAllowed(toolName, config)) {
|
|
14368
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
13250
14369
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
|
|
13251
14370
|
this.pushEvent({
|
|
13252
14371
|
type: 'tool-result',
|
|
@@ -13423,6 +14542,8 @@ export class AgentFramework {
|
|
|
13423
14542
|
.catch((error) => {
|
|
13424
14543
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
13425
14544
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
|
|
14545
|
+
// RFC-007: transport error, closed connection or timeout — no result.
|
|
14546
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
13426
14547
|
|
|
13427
14548
|
this.pushEvent({
|
|
13428
14549
|
type: 'tool-result',
|
|
@@ -13449,6 +14570,8 @@ export class AgentFramework {
|
|
|
13449
14570
|
const home = this.conversationAgentHomes.get(agentName);
|
|
13450
14571
|
if (home) {
|
|
13451
14572
|
const reject = (error: string): void => {
|
|
14573
|
+
// RFC-007: the guard refuses before the tool runs — no lifecycle events.
|
|
14574
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
13452
14575
|
this.emitTrace({
|
|
13453
14576
|
type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
|
|
13454
14577
|
error: `conversation agent ${agentName}: ${error}`,
|
|
@@ -13532,6 +14655,7 @@ export class AgentFramework {
|
|
|
13532
14655
|
.catch((error) => {
|
|
13533
14656
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
13534
14657
|
this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
|
|
14658
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
13535
14659
|
this.pushEvent({
|
|
13536
14660
|
type: 'tool-result',
|
|
13537
14661
|
callId: call.id,
|
|
@@ -13770,7 +14894,8 @@ export class AgentFramework {
|
|
|
13770
14894
|
if (announce && this.channelRegistry) {
|
|
13771
14895
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
13772
14896
|
const agent = this.agents.get(agentName);
|
|
13773
|
-
|
|
14897
|
+
const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
|
|
14898
|
+
if (silentTurn || agent?.proseRouting === 'disabled') {
|
|
13774
14899
|
console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
|
|
13775
14900
|
} else if (agent?.proseRouting === 'explicit') {
|
|
13776
14901
|
// Explicit mode: announce to the turn's sticky prose target if the
|
|
@@ -14614,6 +15739,7 @@ export class AgentFramework {
|
|
|
14614
15739
|
} catch (error) {
|
|
14615
15740
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
14616
15741
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
15742
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
14617
15743
|
result = { success: false, error: err.message, isError: true };
|
|
14618
15744
|
}
|
|
14619
15745
|
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
@@ -14638,6 +15764,7 @@ export class AgentFramework {
|
|
|
14638
15764
|
.catch((error) => {
|
|
14639
15765
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
14640
15766
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
15767
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
14641
15768
|
this.pushEvent({
|
|
14642
15769
|
type: 'tool-result',
|
|
14643
15770
|
callId: call.id,
|