@animalabs/agent-framework 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +161 -7
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1183 -87
- 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 +74 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +156 -10
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +592 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +12 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +33 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/channels-changed-respond-first.test.d.ts +2 -0
- package/dist/test/channels-changed-respond-first.test.d.ts.map +1 -0
- package/dist/test/channels-changed-respond-first.test.js +74 -0
- package/dist/test/channels-changed-respond-first.test.js.map +1 -0
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/journal-tool.test.d.ts +2 -0
- package/dist/test/journal-tool.test.d.ts.map +1 -0
- package/dist/test/journal-tool.test.js +33 -0
- package/dist/test/journal-tool.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +69 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/overbudget-drain-breaker.test.js +44 -4
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1215 -91
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +170 -10
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +730 -0
- package/src/mcpl/transport.ts +35 -1
- package/src/mcpl/types.ts +69 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
package/src/framework.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF
|
|
|
5
5
|
import { JsStore } from '@animalabs/chronicle';
|
|
6
6
|
import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
|
|
7
7
|
import { MembraneError } from '@animalabs/membrane';
|
|
8
|
-
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
|
|
8
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
|
|
9
9
|
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
10
10
|
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
|
|
11
11
|
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
@@ -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,31 @@ 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
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
6998
|
+
if (event.triggerInference) {
|
|
6999
|
+
this.pendingRequests.push({
|
|
7000
|
+
agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
|
|
7001
|
+
channelId: event.channelId,
|
|
7002
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
7003
|
+
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
7004
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
7005
|
+
});
|
|
7006
|
+
}
|
|
7007
|
+
return { agent: target.name, messageId: id };
|
|
7008
|
+
}
|
|
7009
|
+
if (this.conversationRouter) return undefined; // the trunk never holds channel traffic
|
|
7010
|
+
event = { ...event, targetAgents: [target.name] };
|
|
7011
|
+
} else if (this.conversationRouter) {
|
|
7012
|
+
return this.routeConversationIncoming(event, metadata);
|
|
6776
7013
|
}
|
|
6777
7014
|
|
|
6778
7015
|
// Addressed-while-closed invitation — parity with the push-event path
|
|
@@ -6819,7 +7056,8 @@ export class AgentFramework {
|
|
|
6819
7056
|
metadata.tuneOut = { epochId: divert.epochId };
|
|
6820
7057
|
}
|
|
6821
7058
|
|
|
6822
|
-
const
|
|
7059
|
+
const placement: CoalescingPlacement = { agent: '' };
|
|
7060
|
+
const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
6823
7061
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
6824
7062
|
|
|
6825
7063
|
if (event.triggerInference && !divert) {
|
|
@@ -6845,9 +7083,11 @@ export class AgentFramework {
|
|
|
6845
7083
|
// Addressed messages outrank ambient chatter when a batched wake
|
|
6846
7084
|
// picks the turn's frozen speech locus.
|
|
6847
7085
|
addressed,
|
|
7086
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6848
7087
|
});
|
|
6849
7088
|
}
|
|
6850
7089
|
}
|
|
7090
|
+
return placement.agent ? placement : undefined;
|
|
6851
7091
|
}
|
|
6852
7092
|
|
|
6853
7093
|
/**
|
|
@@ -6866,9 +7106,10 @@ export class AgentFramework {
|
|
|
6866
7106
|
metadata?: Record<string, unknown>;
|
|
6867
7107
|
tags?: string[];
|
|
6868
7108
|
triggerInference?: boolean;
|
|
7109
|
+
coalescingSubject?: string;
|
|
6869
7110
|
},
|
|
6870
7111
|
messageMetadata: Record<string, unknown>,
|
|
6871
|
-
): Promise<
|
|
7112
|
+
): Promise<CoalescingPlacement | undefined> {
|
|
6872
7113
|
const router = this.conversationRouter!;
|
|
6873
7114
|
const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
|
|
6874
7115
|
|
|
@@ -6945,8 +7186,10 @@ export class AgentFramework {
|
|
|
6945
7186
|
channelId: event.channelId,
|
|
6946
7187
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
6947
7188
|
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
7189
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6948
7190
|
});
|
|
6949
7191
|
}
|
|
7192
|
+
return { agent: agent.name, messageId: id };
|
|
6950
7193
|
}
|
|
6951
7194
|
|
|
6952
7195
|
/**
|
|
@@ -6999,6 +7242,8 @@ export class AgentFramework {
|
|
|
6999
7242
|
|
|
7000
7243
|
const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
|
|
7001
7244
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
7245
|
+
this.restoreToolResultGuardSetting(agent);
|
|
7246
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
7002
7247
|
this.agents.set(name, agent);
|
|
7003
7248
|
this.agentConfigs.set(name, config);
|
|
7004
7249
|
this.conversationAgentHomes.set(name, channelId);
|
|
@@ -7177,6 +7422,19 @@ export class AgentFramework {
|
|
|
7177
7422
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
7178
7423
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
7179
7424
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
7425
|
+
// The label is for reading; the prefix must be ONE token that resolves back
|
|
7426
|
+
// here, on this server (a DM label "DM: alice" quoted verbatim parses as
|
|
7427
|
+
// target "#DM:"). A registry that can name targets is authoritative: no safe
|
|
7428
|
+
// token means no prefix option. A stand-in without proseTargetFor gets a
|
|
7429
|
+
// whitespace-free guess, never a label with spaces.
|
|
7430
|
+
const namer = this.channelRegistry as { proseTargetFor?: (id: string, serverId?: string) => string | undefined };
|
|
7431
|
+
const target = typeof namer.proseTargetFor === 'function'
|
|
7432
|
+
? namer.proseTargetFor(opts.channelId, opts.serverId)
|
|
7433
|
+
: [label ? `#${label}` : undefined, opts.channelId].find((t): t is string => !!t && !/\s/.test(t));
|
|
7434
|
+
const replyOption = target
|
|
7435
|
+
? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
|
|
7436
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
|
|
7437
|
+
: `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
|
|
7180
7438
|
const missedCount = opts.missedMessages ?? 0;
|
|
7181
7439
|
const missedNote =
|
|
7182
7440
|
missedCount > 0
|
|
@@ -7192,8 +7450,7 @@ export class AgentFramework {
|
|
|
7192
7450
|
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
7193
7451
|
missedNote +
|
|
7194
7452
|
`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` +
|
|
7453
|
+
replyOption +
|
|
7197
7454
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
7198
7455
|
(maxBackscroll > 0
|
|
7199
7456
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -7213,10 +7470,454 @@ export class AgentFramework {
|
|
|
7213
7470
|
};
|
|
7214
7471
|
}
|
|
7215
7472
|
|
|
7473
|
+
// ==========================================================================
|
|
7474
|
+
// RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
|
|
7475
|
+
// ==========================================================================
|
|
7476
|
+
|
|
7477
|
+
/**
|
|
7478
|
+
* RFC-006 §3.2 binding identity: the configured server id bound to its
|
|
7479
|
+
* endpoint/command. Survives reconnects to the same peer; changes when the
|
|
7480
|
+
* id is reassigned to another endpoint (restartMcplServer with a new config).
|
|
7481
|
+
*/
|
|
7482
|
+
private coalescingBinding(serverId: string): string {
|
|
7483
|
+
const config = this.mcplServerConfigs.get(serverId);
|
|
7484
|
+
return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
|
|
7485
|
+
}
|
|
7486
|
+
|
|
7487
|
+
private initializePushCoalescer(): void {
|
|
7488
|
+
const coalescer = new PushCoalescer<CoalescedDelivery>({
|
|
7489
|
+
isUnread: (p) => this.isUnreadPlacement(p),
|
|
7490
|
+
remove: (p) => this.removePlacement(p),
|
|
7491
|
+
deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
|
|
7492
|
+
wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
|
|
7493
|
+
cancelWake: (subject) => this.cancelCoalescedWakes(subject),
|
|
7494
|
+
authorized: (occ) => this.coalescedAuthorized(occ),
|
|
7495
|
+
audience: (occ) => this.coalescedAudience(occ),
|
|
7496
|
+
render: (occ, params) => {
|
|
7497
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
7498
|
+
if (!server?.isConnected) throw new Error('server disconnected');
|
|
7499
|
+
return server.sendPushRender(params);
|
|
7500
|
+
},
|
|
7501
|
+
audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
|
|
7502
|
+
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
|
|
7503
|
+
recordReceipt: (record) => {
|
|
7504
|
+
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
7505
|
+
// must not lose its receipt, the subject's birth, or (deferred) the
|
|
7506
|
+
// accepted work itself to a crash. A failure here fails the acceptance.
|
|
7507
|
+
this.coalescingRecentReceipts.push(record);
|
|
7508
|
+
this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
|
|
7509
|
+
},
|
|
7510
|
+
wasPublished: (subject, eventId) => {
|
|
7511
|
+
// Boot-time only: the occurrence's durable delivery identity
|
|
7512
|
+
// (subject + eventId in message metadata) in any agent's context.
|
|
7513
|
+
for (const agent of this.agents.values()) {
|
|
7514
|
+
try {
|
|
7515
|
+
if (agent.getContextManager().getAllMessages().some((m) =>
|
|
7516
|
+
m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId)) return true;
|
|
7517
|
+
} catch { /* a context that cannot be read cannot prove publication */ }
|
|
7518
|
+
}
|
|
7519
|
+
return false;
|
|
7520
|
+
},
|
|
7521
|
+
commit: () => this.commitCoalescingDurable(),
|
|
7522
|
+
saveNow: (snapshot) => {
|
|
7523
|
+
if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
|
|
7524
|
+
this.coalescingSnapshotDirty = null;
|
|
7525
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot);
|
|
7526
|
+
this.coalescingRecentReceipts = [];
|
|
7527
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
7528
|
+
},
|
|
7529
|
+
});
|
|
7530
|
+
try {
|
|
7531
|
+
coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID) as CoalescingSnapshot | null);
|
|
7532
|
+
const recent = this.store.getStateJson(COALESCING_RECENT_ID) as CoalescingReceiptRecord[] | null;
|
|
7533
|
+
if (Array.isArray(recent)) {
|
|
7534
|
+
coalescer.restoreReceipts(recent);
|
|
7535
|
+
// Until the next snapshot flush checkpoints the recovered state, these
|
|
7536
|
+
// records are its only durable form: keep them in the writable buffer
|
|
7537
|
+
// so the next acceptance appends to them instead of replacing them.
|
|
7538
|
+
this.coalescingRecentReceipts = recent;
|
|
7539
|
+
}
|
|
7540
|
+
} catch (err) {
|
|
7541
|
+
console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
|
|
7542
|
+
}
|
|
7543
|
+
this.pushCoalescer = coalescer;
|
|
7544
|
+
// Batches restored from a snapshot still owe the agent a wake (§14 v41).
|
|
7545
|
+
for (const occ of coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ);
|
|
7546
|
+
}
|
|
7547
|
+
|
|
7548
|
+
/**
|
|
7549
|
+
* Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
|
|
7550
|
+
* written so far is fsynced before the caller proceeds. Group-committed —
|
|
7551
|
+
* all acceptances that arrive in one event-loop turn share one
|
|
7552
|
+
* `store.sync()` — so throughput is bounded by fsync rate per batch, not
|
|
7553
|
+
* per message.
|
|
7554
|
+
*/
|
|
7555
|
+
private commitCoalescingDurable(): Promise<void> {
|
|
7556
|
+
if (!this.coalescingCommit) {
|
|
7557
|
+
this.coalescingCommit = new Promise<void>((resolve, reject) => {
|
|
7558
|
+
setImmediate(() => {
|
|
7559
|
+
this.coalescingCommit = null;
|
|
7560
|
+
try { this.store.sync(); resolve(); } catch (err) { reject(err); }
|
|
7561
|
+
});
|
|
7562
|
+
});
|
|
7563
|
+
}
|
|
7564
|
+
return this.coalescingCommit;
|
|
7565
|
+
}
|
|
7566
|
+
|
|
7567
|
+
private scheduleCoalescingSnapshot(snapshot: () => CoalescingSnapshot): void {
|
|
7568
|
+
this.coalescingSnapshotDirty = snapshot;
|
|
7569
|
+
if (this.coalescingSaveTimer) return;
|
|
7570
|
+
const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
|
|
7571
|
+
timer.unref?.();
|
|
7572
|
+
this.coalescingSaveTimer = timer;
|
|
7573
|
+
}
|
|
7574
|
+
|
|
7575
|
+
private flushCoalescingSnapshot(): void {
|
|
7576
|
+
if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
|
|
7577
|
+
const snapshot = this.coalescingSnapshotDirty;
|
|
7578
|
+
this.coalescingSnapshotDirty = null;
|
|
7579
|
+
if (!snapshot) return;
|
|
7580
|
+
try {
|
|
7581
|
+
this.store.setStateJson(COALESCING_STATE_ID, snapshot());
|
|
7582
|
+
this.coalescingRecentReceipts = [];
|
|
7583
|
+
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
7584
|
+
} catch (err) {
|
|
7585
|
+
console.error('[coalescing] could not persist coalescing state:', err);
|
|
7586
|
+
}
|
|
7587
|
+
}
|
|
7588
|
+
|
|
7589
|
+
/** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
|
|
7590
|
+
private admitCoalesced<T>(work: () => Promise<T>): Promise<T> {
|
|
7591
|
+
const run = this.coalescingAdmission.then(work, work);
|
|
7592
|
+
this.coalescingAdmission = run.then(() => undefined, () => undefined);
|
|
7593
|
+
return run;
|
|
7594
|
+
}
|
|
7595
|
+
|
|
7596
|
+
private handleCoalescedIncoming(
|
|
7597
|
+
serverId: string,
|
|
7598
|
+
message: ChannelIncomingMessage,
|
|
7599
|
+
event: CoalescedChannelEvent,
|
|
7600
|
+
): Promise<ChannelIncomingMessageResult> {
|
|
7601
|
+
return this.admitCoalesced(async () => {
|
|
7602
|
+
if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
7603
|
+
validateCoalesceMember(message.coalesce, 'channel');
|
|
7604
|
+
if (typeof message.eventId !== 'string' || !message.eventId) {
|
|
7605
|
+
throw new CoalesceError('eventId', 'eventId is required with coalesce');
|
|
7606
|
+
}
|
|
7607
|
+
if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7608
|
+
validateCoalescedContent(message.content);
|
|
7609
|
+
const c = message.coalesce;
|
|
7610
|
+
const result = await this.pushCoalescer.accept({
|
|
7611
|
+
serverId,
|
|
7612
|
+
binding: this.coalescingBinding(serverId),
|
|
7613
|
+
scope: { kind: 'channel', id: message.channelId },
|
|
7614
|
+
key: c.key,
|
|
7615
|
+
eventId: message.eventId,
|
|
7616
|
+
timestamp: message.timestamp,
|
|
7617
|
+
retract: !!c.retract,
|
|
7618
|
+
deferred: false,
|
|
7619
|
+
initial: !!c.initial,
|
|
7620
|
+
tags: event.tags,
|
|
7621
|
+
content: message.content,
|
|
7622
|
+
identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
|
|
7623
|
+
event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
|
|
7624
|
+
});
|
|
7625
|
+
return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
|
|
7626
|
+
});
|
|
7627
|
+
}
|
|
7628
|
+
|
|
7629
|
+
private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise<PushEventResult> {
|
|
7630
|
+
return this.admitCoalesced(async () => {
|
|
7631
|
+
if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
|
|
7632
|
+
validateCoalesceMember(params.coalesce, 'push');
|
|
7633
|
+
if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
|
|
7634
|
+
if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7635
|
+
validateCoalescedContent(params.payload?.content);
|
|
7636
|
+
const c = params.coalesce;
|
|
7637
|
+
if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId);
|
|
7638
|
+
const origin = params.origin ?? {};
|
|
7639
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
7640
|
+
return this.pushCoalescer.accept({
|
|
7641
|
+
serverId,
|
|
7642
|
+
binding: this.coalescingBinding(serverId),
|
|
7643
|
+
featureSet: params.featureSet,
|
|
7644
|
+
scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
|
|
7645
|
+
key: c.key,
|
|
7646
|
+
eventId: params.eventId,
|
|
7647
|
+
timestamp: params.timestamp,
|
|
7648
|
+
retract: !!c.retract,
|
|
7649
|
+
deferred: !!c.deferred,
|
|
7650
|
+
initial: !!c.initial,
|
|
7651
|
+
data: c.data,
|
|
7652
|
+
tags: event.tags,
|
|
7653
|
+
content: params.payload.content,
|
|
7654
|
+
...(c.channelId ? {
|
|
7655
|
+
identity: {
|
|
7656
|
+
...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
|
|
7657
|
+
...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
|
|
7658
|
+
...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}),
|
|
7659
|
+
},
|
|
7660
|
+
} : {}),
|
|
7661
|
+
event: { lane: 'push', event },
|
|
7662
|
+
});
|
|
7663
|
+
});
|
|
7664
|
+
}
|
|
7665
|
+
|
|
7666
|
+
/** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
|
|
7667
|
+
private assertChannelScopedPush(serverId: string, channelId: string): void {
|
|
7668
|
+
const server = this.mcplServerRegistry?.getServer(serverId);
|
|
7669
|
+
if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
|
|
7670
|
+
throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
|
|
7671
|
+
}
|
|
7672
|
+
if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
|
|
7673
|
+
throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
|
|
7674
|
+
}
|
|
7675
|
+
}
|
|
7676
|
+
|
|
7677
|
+
private coalescedAuthorized(occ: CoalescedOccurrence<CoalescedDelivery>): boolean {
|
|
7678
|
+
const server = this.mcplServerRegistry?.getServer(occ.serverId);
|
|
7679
|
+
if (!server?.isConnected || !server.policyEstablished) return false;
|
|
7680
|
+
// §3.2: work admitted under another binding (the id since reassigned to a
|
|
7681
|
+
// different endpoint) never acquires the replacement peer's authority —
|
|
7682
|
+
// and its private notice data is never sent to that peer.
|
|
7683
|
+
if (occ.binding !== this.coalescingBinding(occ.serverId)) return false;
|
|
7684
|
+
if (occ.event.lane === 'push') {
|
|
7685
|
+
if (!server.grant.has('pushEvents')) return false;
|
|
7686
|
+
try { this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet); } catch { return false; }
|
|
7687
|
+
}
|
|
7688
|
+
if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
|
|
7689
|
+
// §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
|
|
7690
|
+
if (!server.grant.has('channels.incoming')) return false;
|
|
7691
|
+
if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
|
|
7692
|
+
return !!this.channelRegistry?.getDescriptor(occ.scope.id);
|
|
7693
|
+
}
|
|
7694
|
+
return true;
|
|
7695
|
+
}
|
|
7696
|
+
|
|
7697
|
+
private async coalescedAudience(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<string[]> {
|
|
7698
|
+
if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
|
|
7699
|
+
const explicit = occ.event.event.targetAgents;
|
|
7700
|
+
if (explicit) return explicit.filter((n) => this.agents.has(n));
|
|
7701
|
+
if (occ.scope.kind === 'channel' && this.conversationRouter) {
|
|
7702
|
+
const router = this.conversationRouter;
|
|
7703
|
+
const bound = router.getBinding(occ.scope.id)?.agentName;
|
|
7704
|
+
if (bound) return this.agents.has(bound) ? [bound] : [];
|
|
7705
|
+
// No fork yet: the same bind decision an incoming message would get. A
|
|
7706
|
+
// batch that qualifies spawns its fork now so it has someone to wake.
|
|
7707
|
+
const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
|
|
7708
|
+
const decision = router.route({
|
|
7709
|
+
channelId: occ.scope.id,
|
|
7710
|
+
mentioned: metadata.mentioned === true,
|
|
7711
|
+
kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
|
|
7712
|
+
});
|
|
7713
|
+
if (decision.kind !== 'spawn') return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
|
|
7714
|
+
try {
|
|
7715
|
+
await this.createConversationAgent(decision.agentName, occ.scope.id);
|
|
7716
|
+
router.bind(occ.scope.id, decision.agentName, decision.generation);
|
|
7717
|
+
this.persistConversationRouterState();
|
|
7718
|
+
return [decision.agentName];
|
|
7719
|
+
} catch (err) {
|
|
7720
|
+
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
|
|
7721
|
+
return [];
|
|
7722
|
+
}
|
|
7723
|
+
}
|
|
7724
|
+
return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
7725
|
+
}
|
|
7726
|
+
|
|
7727
|
+
/** Deliver through the lane's ordinary path; report where it landed. */
|
|
7728
|
+
private async deliverCoalesced(
|
|
7729
|
+
occ: CoalescedOccurrence<CoalescedDelivery>,
|
|
7730
|
+
materialized?: McplContentBlock[],
|
|
7731
|
+
assemblingFor?: string,
|
|
7732
|
+
): Promise<CoalescingPlacement | undefined> {
|
|
7733
|
+
const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
7734
|
+
const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
|
|
7735
|
+
if (occ.event.lane === 'channel') {
|
|
7736
|
+
const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
|
|
7737
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
7738
|
+
await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
|
|
7739
|
+
return placement;
|
|
7740
|
+
}
|
|
7741
|
+
if (occ.scope.kind === 'channel') {
|
|
7742
|
+
// §3.2: a channel-scoped push addresses the channel's subject namespace,
|
|
7743
|
+
// so it is delivered as a message OF that channel — same routing
|
|
7744
|
+
// (conversation forks, tune-out, closed-channel invitation), same reply
|
|
7745
|
+
// locus, and the subject's stable platform identity (§3.1, vector 43).
|
|
7746
|
+
const push = occ.event.event;
|
|
7747
|
+
const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
|
|
7748
|
+
const origin = push.origin ?? {};
|
|
7749
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
7750
|
+
const event: CoalescedChannelEvent = {
|
|
7751
|
+
type: 'mcpl:channel-incoming',
|
|
7752
|
+
serverId: occ.serverId,
|
|
7753
|
+
channelId: occ.scope.id,
|
|
7754
|
+
messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
|
|
7755
|
+
threadId: identity.threadId ?? str(origin.threadId),
|
|
7756
|
+
author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
|
|
7757
|
+
content: materialized ? materialized.map(convertPushBlock) : push.content,
|
|
7758
|
+
timestamp: occ.timestamp,
|
|
7759
|
+
metadata: origin,
|
|
7760
|
+
tags: push.tags,
|
|
7761
|
+
triggerInference: assemblingFor ? false : push.triggerInference,
|
|
7762
|
+
targetAgents: push.targetAgents,
|
|
7763
|
+
coalescingSubject,
|
|
7764
|
+
eventId: occ.eventId,
|
|
7765
|
+
...narrowing,
|
|
7766
|
+
...(assemblingFor ? { assemblingFor } : {}),
|
|
7767
|
+
};
|
|
7768
|
+
const placement = await this.handleMcplChannelIncoming(event);
|
|
7769
|
+
await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
|
|
7770
|
+
return placement;
|
|
7771
|
+
}
|
|
7772
|
+
const event: McplPushEvent = {
|
|
7773
|
+
...occ.event.event,
|
|
7774
|
+
eventId: occ.eventId,
|
|
7775
|
+
timestamp: occ.timestamp,
|
|
7776
|
+
...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
|
|
7777
|
+
coalescingSubject,
|
|
7778
|
+
// §5.2: the materialized occurrence is consumed by the request being
|
|
7779
|
+
// assembled; the wake that started it is not repeated.
|
|
7780
|
+
...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
|
|
7781
|
+
...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
|
|
7782
|
+
};
|
|
7783
|
+
const placement = this.handleMcplPushEvent(event);
|
|
7784
|
+
await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
|
|
7785
|
+
return placement;
|
|
7786
|
+
}
|
|
7787
|
+
|
|
7788
|
+
private async wakeForCoalescedBatch(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<void> {
|
|
7789
|
+
const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
7790
|
+
this.cancelCoalescedWakes(subject);
|
|
7791
|
+
if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return;
|
|
7792
|
+
const origin = occ.event.event.origin;
|
|
7793
|
+
// The wake carries the batch's channel so the turn freezes its reply
|
|
7794
|
+
// locus there (the materialized delivery at assembly wakes nobody).
|
|
7795
|
+
const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
|
|
7796
|
+
const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
|
|
7797
|
+
for (const agentName of await this.coalescedAudience(occ)) {
|
|
7798
|
+
this.pendingRequests.push({
|
|
7799
|
+
agentName,
|
|
7800
|
+
reason: 'mcpl:push-event',
|
|
7801
|
+
source: occ.serverId,
|
|
7802
|
+
timestamp: Date.now(),
|
|
7803
|
+
channelId,
|
|
7804
|
+
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
7805
|
+
addressed: isAddressedMessage(occ.tags, origin),
|
|
7806
|
+
coalescingSubject: subject,
|
|
7807
|
+
});
|
|
7808
|
+
}
|
|
7809
|
+
}
|
|
7810
|
+
|
|
7811
|
+
/** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
|
|
7812
|
+
private cancelCoalescedWakes(subject: string): void {
|
|
7813
|
+
this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
|
|
7814
|
+
for (const cooldown of this.providerAccelerationCooldowns.values()) {
|
|
7815
|
+
cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
|
|
7816
|
+
}
|
|
7817
|
+
}
|
|
7818
|
+
|
|
7819
|
+
/**
|
|
7820
|
+
* A placement in the deferred queue is upgraded in place once the flush has
|
|
7821
|
+
* stored it: the stored copy carries `metadata.deferredWriteId`, found by
|
|
7822
|
+
* scanning the unread tail (above the watermark) — never below it.
|
|
7823
|
+
*/
|
|
7824
|
+
private resolvePlacement(p: CoalescingPlacement): void {
|
|
7825
|
+
if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId)) return;
|
|
7826
|
+
const agent = this.agents.get(p.agent);
|
|
7827
|
+
if (!agent) return;
|
|
7828
|
+
const cm = agent.getContextManager();
|
|
7829
|
+
const watermark = agent.getConsumedWatermark();
|
|
7830
|
+
const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
|
|
7831
|
+
const all = cm.getAllMessages();
|
|
7832
|
+
for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
|
|
7833
|
+
if (all[i].metadata?.deferredWriteId === p.deferredId) {
|
|
7834
|
+
p.messageId = all[i].id;
|
|
7835
|
+
delete p.deferredId;
|
|
7836
|
+
return;
|
|
7837
|
+
}
|
|
7838
|
+
}
|
|
7839
|
+
}
|
|
7840
|
+
|
|
7841
|
+
private isUnreadPlacement(p: CoalescingPlacement): boolean {
|
|
7842
|
+
this.resolvePlacement(p);
|
|
7843
|
+
if (p.deferredId) return this.deferredMessages.some((m) => m.id === p.deferredId);
|
|
7844
|
+
if (!p.messageId) return false;
|
|
7845
|
+
const agent = this.agents.get(p.agent);
|
|
7846
|
+
return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
|
|
7847
|
+
}
|
|
7848
|
+
|
|
7849
|
+
/**
|
|
7850
|
+
* Unread = stored, above the agent's consumed watermark on the current
|
|
7851
|
+
* branch, not a body-group shard (immutable), and not folded into any
|
|
7852
|
+
* summary (compression is consumption, §3.3). Conservative on every doubt.
|
|
7853
|
+
*/
|
|
7854
|
+
private isUnreadStoredMessage(agent: Agent, messageId: MessageId): boolean {
|
|
7855
|
+
const cm = agent.getContextManager();
|
|
7856
|
+
let message: ReturnType<ContextManager['getMessage']>;
|
|
7857
|
+
try { message = cm.getMessage(messageId); } catch { return false; }
|
|
7858
|
+
if (!message || message.bodyGroupId) return false;
|
|
7859
|
+
// Every agent that can read this message must not have compiled past it.
|
|
7860
|
+
// Residents share one message slot, so a message one of them read is
|
|
7861
|
+
// history for all of them (the never-rewrite rule is about the message,
|
|
7862
|
+
// not the agent that happened to be asked). A tune-out-diverted message
|
|
7863
|
+
// never enters a resident's compiled view; only the subconscious reads
|
|
7864
|
+
// it through its merged view — no subconscious, nobody reads it.
|
|
7865
|
+
let readers: Agent[];
|
|
7866
|
+
if ((message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) {
|
|
7867
|
+
const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
|
|
7868
|
+
if (!subconscious) return true;
|
|
7869
|
+
readers = [subconscious];
|
|
7870
|
+
} else {
|
|
7871
|
+
readers = this.sharedSlotAgents.has(agent)
|
|
7872
|
+
? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
|
|
7873
|
+
: [agent];
|
|
7874
|
+
}
|
|
7875
|
+
const branch = cm.currentBranch().name;
|
|
7876
|
+
const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
|
|
7877
|
+
for (const reader of readers) {
|
|
7878
|
+
const watermark = reader.getConsumedWatermark();
|
|
7879
|
+
if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence) return false;
|
|
7880
|
+
// Compression is consumption (§3.3): folded into any reader's summary.
|
|
7881
|
+
try {
|
|
7882
|
+
const rcm = reader.getContextManager();
|
|
7883
|
+
const strategy = rcm.getStrategy() as { getSummary?: (id: string) => { sourceIds?: string[]; sourceLevel?: number } | null };
|
|
7884
|
+
for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
|
|
7885
|
+
const entry = strategy.getSummary?.(summary.id);
|
|
7886
|
+
if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId)) return false;
|
|
7887
|
+
}
|
|
7888
|
+
} catch {
|
|
7889
|
+
return false;
|
|
7890
|
+
}
|
|
7891
|
+
}
|
|
7892
|
+
return true;
|
|
7893
|
+
}
|
|
7894
|
+
|
|
7895
|
+
private removePlacement(p: CoalescingPlacement): boolean {
|
|
7896
|
+
this.resolvePlacement(p);
|
|
7897
|
+
if (p.deferredId) {
|
|
7898
|
+
const before = this.deferredMessages.length;
|
|
7899
|
+
this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
|
|
7900
|
+
if (this.deferredMessages.length === before) return false;
|
|
7901
|
+
if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
|
|
7902
|
+
return true;
|
|
7903
|
+
}
|
|
7904
|
+
if (!p.messageId) return false;
|
|
7905
|
+
const agent = this.agents.get(p.agent);
|
|
7906
|
+
if (!agent) return false;
|
|
7907
|
+
try {
|
|
7908
|
+
agent.getContextManager().removeMessage(p.messageId);
|
|
7909
|
+
this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
|
|
7910
|
+
return true;
|
|
7911
|
+
} catch (err) {
|
|
7912
|
+
console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
|
|
7913
|
+
return false;
|
|
7914
|
+
}
|
|
7915
|
+
}
|
|
7916
|
+
|
|
7216
7917
|
/**
|
|
7217
7918
|
* Convert an MCPL push event to a context message.
|
|
7218
7919
|
*/
|
|
7219
|
-
private handleMcplPushEvent(event: McplPushEvent):
|
|
7920
|
+
private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined {
|
|
7220
7921
|
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
7221
7922
|
if (triggerChannel && this.channelRegistry) {
|
|
7222
7923
|
this.channelRegistry.ensureChannelRegistered(
|
|
@@ -7234,6 +7935,7 @@ export class AgentFramework {
|
|
|
7234
7935
|
eventId: event.eventId,
|
|
7235
7936
|
triggered: event.triggerInference ?? false,
|
|
7236
7937
|
...(event.tags ? { tags: event.tags } : {}),
|
|
7938
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
7237
7939
|
};
|
|
7238
7940
|
// `origin.channelId` is the server-internal raw id (a bare Discord
|
|
7239
7941
|
// snowflake) — unroutable as a locus and unresolvable by the agent. Store
|
|
@@ -7266,8 +7968,24 @@ export class AgentFramework {
|
|
|
7266
7968
|
}
|
|
7267
7969
|
}
|
|
7268
7970
|
|
|
7269
|
-
|
|
7270
|
-
|
|
7971
|
+
// Silent heartbeat ticks are control-plane wakes, not autobiography.
|
|
7972
|
+
// Accept the no-message path only for the heartbeat feature's own exact
|
|
7973
|
+
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
7974
|
+
// content merely by setting `origin.silent`.
|
|
7975
|
+
const silentHeartbeat =
|
|
7976
|
+
event.serverId === 'heartbeat' &&
|
|
7977
|
+
event.featureSet === 'heartbeat' &&
|
|
7978
|
+
event.origin?.source === 'heartbeat' &&
|
|
7979
|
+
event.origin?.silent === true &&
|
|
7980
|
+
content.length === 0;
|
|
7981
|
+
|
|
7982
|
+
const placement: CoalescingPlacement = { agent: '' };
|
|
7983
|
+
if (!silentHeartbeat) {
|
|
7984
|
+
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
7985
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
7986
|
+
} else {
|
|
7987
|
+
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
|
|
7988
|
+
}
|
|
7271
7989
|
|
|
7272
7990
|
if (event.triggerInference) {
|
|
7273
7991
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
@@ -7285,9 +8003,17 @@ export class AgentFramework {
|
|
|
7285
8003
|
// they must outrank ambient chatter in a batched wake's locus
|
|
7286
8004
|
// selection just like their channels/incoming counterparts.
|
|
7287
8005
|
addressed: isAddressedMessage(event.tags, event.origin),
|
|
8006
|
+
...(silentHeartbeat ? {
|
|
8007
|
+
suppressProse: true,
|
|
8008
|
+
ephemeralSystemPrompt:
|
|
8009
|
+
'[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
|
|
8010
|
+
'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
|
|
8011
|
+
} : {}),
|
|
8012
|
+
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
7288
8013
|
});
|
|
7289
8014
|
}
|
|
7290
8015
|
}
|
|
8016
|
+
return placement.agent ? placement : undefined;
|
|
7291
8017
|
}
|
|
7292
8018
|
|
|
7293
8019
|
/**
|
|
@@ -7445,7 +8171,9 @@ export class AgentFramework {
|
|
|
7445
8171
|
// route the resumed turn into the wrong channel.
|
|
7446
8172
|
const newestByKey = new Map<string, InferenceRequest>();
|
|
7447
8173
|
for (const request of parked) {
|
|
7448
|
-
|
|
8174
|
+
// A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
|
|
8175
|
+
// must not absorb an unrelated wake that would vanish with it.
|
|
8176
|
+
const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
|
|
7449
8177
|
const prev = newestByKey.get(key);
|
|
7450
8178
|
if (!prev || request.timestamp >= prev.timestamp) {
|
|
7451
8179
|
newestByKey.set(key, request);
|
|
@@ -7574,8 +8302,13 @@ export class AgentFramework {
|
|
|
7574
8302
|
if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
|
|
7575
8303
|
}
|
|
7576
8304
|
}
|
|
8305
|
+
// A silent tick never suppresses a genuine coalesced wake. If any
|
|
8306
|
+
// ordinary request shares this batch, the ordinary turn semantics win.
|
|
8307
|
+
const silentOnly = requests.every((r) => r.suppressProse === true);
|
|
7577
8308
|
await this.startAgentStream(agent, {
|
|
7578
8309
|
...trigger,
|
|
8310
|
+
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
|
|
8311
|
+
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
|
|
7579
8312
|
channelId: channelReq?.channelId,
|
|
7580
8313
|
addressed: addressedReq !== undefined,
|
|
7581
8314
|
// A context-budget restart continues the same logical turn: it keeps
|
|
@@ -8140,6 +8873,9 @@ export class AgentFramework {
|
|
|
8140
8873
|
turnToken: number,
|
|
8141
8874
|
ownsProviderGate: boolean,
|
|
8142
8875
|
): Promise<boolean> {
|
|
8876
|
+
const continuingTurn = trigger?.reason === 'context_budget_restart'
|
|
8877
|
+
|| trigger?.reason === 'tool_result_guard_retry';
|
|
8878
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
8143
8879
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
8144
8880
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
8145
8881
|
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
@@ -8156,7 +8892,7 @@ export class AgentFramework {
|
|
|
8156
8892
|
// products — undoing the turn must not destroy them.
|
|
8157
8893
|
if (
|
|
8158
8894
|
attempt === 0 &&
|
|
8159
|
-
|
|
8895
|
+
!continuingTurn &&
|
|
8160
8896
|
this.deferredMessages.length > 0
|
|
8161
8897
|
) {
|
|
8162
8898
|
{
|
|
@@ -8182,8 +8918,20 @@ export class AgentFramework {
|
|
|
8182
8918
|
}
|
|
8183
8919
|
}
|
|
8184
8920
|
|
|
8921
|
+
// RFC-006 §5: deferred batches for this agent render now, at the assembly
|
|
8922
|
+
// boundary — after the deferred flush (same window: turn alive, compile
|
|
8923
|
+
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
8924
|
+
// Not on a context-budget restart: that continues the same logical turn.
|
|
8925
|
+
if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
|
|
8926
|
+
try {
|
|
8927
|
+
await this.pushCoalescer.assemble(agent.name);
|
|
8928
|
+
} catch (err) {
|
|
8929
|
+
console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
|
|
8930
|
+
}
|
|
8931
|
+
}
|
|
8932
|
+
|
|
8185
8933
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
8186
|
-
if (attempt === 0) {
|
|
8934
|
+
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
8187
8935
|
this.recordTurnCheckpoint(agent.name);
|
|
8188
8936
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
8189
8937
|
}
|
|
@@ -8201,7 +8949,7 @@ export class AgentFramework {
|
|
|
8201
8949
|
// after the restart fell through to the hours-old defaultPublishChannel.
|
|
8202
8950
|
// Keep the turn's trigger channel across restarts; only real new turns
|
|
8203
8951
|
// reset it.
|
|
8204
|
-
if (
|
|
8952
|
+
if (!continuingTurn) {
|
|
8205
8953
|
if (trigger?.channelId) {
|
|
8206
8954
|
this.activeTriggerChannels.set(agent.name, trigger.channelId);
|
|
8207
8955
|
} else {
|
|
@@ -8221,12 +8969,12 @@ export class AgentFramework {
|
|
|
8221
8969
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
8222
8970
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
8223
8971
|
// KV stability).
|
|
8224
|
-
if (
|
|
8972
|
+
if (continuingTurn) {
|
|
8225
8973
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
8226
8974
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
8227
8975
|
}
|
|
8228
8976
|
|
|
8229
|
-
if (
|
|
8977
|
+
if (!continuingTurn) {
|
|
8230
8978
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
8231
8979
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
8232
8980
|
if (attempt === 0) {
|
|
@@ -8243,8 +8991,8 @@ export class AgentFramework {
|
|
|
8243
8991
|
this.turnProseDeliveries.delete(agent.name);
|
|
8244
8992
|
this.turnProseSuppressed.delete(agent.name);
|
|
8245
8993
|
this.proseHybridSuppressed.delete(agent.name);
|
|
8246
|
-
if (
|
|
8247
|
-
if (
|
|
8994
|
+
if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
|
|
8995
|
+
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
8248
8996
|
// Explicit and disabled prose routing have no inferred locus.
|
|
8249
8997
|
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
8250
8998
|
// prose target at all. Neither mode freezes or announces a locus.
|
|
@@ -8261,6 +9009,15 @@ export class AgentFramework {
|
|
|
8261
9009
|
}
|
|
8262
9010
|
}
|
|
8263
9011
|
|
|
9012
|
+
// A context-budget restart normally preserves the turn locus, but a
|
|
9013
|
+
// silent control-plane wake must remain locusless even if an explicit tool
|
|
9014
|
+
// opened/pinned a channel mid-turn.
|
|
9015
|
+
if (trigger?.suppressProse) {
|
|
9016
|
+
this.turnLocusPins.delete(agent.name);
|
|
9017
|
+
this.proseTargetPins.delete(agent.name);
|
|
9018
|
+
this.proseContinuations.delete(agent.name);
|
|
9019
|
+
}
|
|
9020
|
+
|
|
8264
9021
|
this.touchEphemeralRun(agent.name, true);
|
|
8265
9022
|
this.emitTrace({
|
|
8266
9023
|
type: 'inference:started',
|
|
@@ -8268,10 +9025,11 @@ export class AgentFramework {
|
|
|
8268
9025
|
// The turn-frozen locus was pinned just above (kept across a
|
|
8269
9026
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
8270
9027
|
channelId: this.turnLocusPins.get(agent.name),
|
|
9028
|
+
...(trigger?.suppressProse ? { silent: true } : {}),
|
|
8271
9029
|
});
|
|
8272
9030
|
// A budget restart continues the same logical inference window. The
|
|
8273
9031
|
// predecessor keeps EventGate liveness until its successor terminates.
|
|
8274
|
-
if (
|
|
9032
|
+
if (!continuingTurn) {
|
|
8275
9033
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
8276
9034
|
}
|
|
8277
9035
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
@@ -8286,18 +9044,14 @@ export class AgentFramework {
|
|
|
8286
9044
|
// idempotent per channel, and owns the 7s refresh); the catch below stops
|
|
8287
9045
|
// it on the no-driveStream failure paths (e.g. a compile refusal).
|
|
8288
9046
|
const earlyTypingChannel =
|
|
8289
|
-
|
|
9047
|
+
turnProseRouting === 'explicit'
|
|
8290
9048
|
? trigger?.channelId ?? null
|
|
8291
9049
|
: this.turnLocusPins.get(agent.name) ?? null;
|
|
8292
9050
|
if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
8293
9051
|
|
|
8294
9052
|
try {
|
|
8295
9053
|
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);
|
|
9054
|
+
const tools = this.agentToolSurface(agent, requestSnapshot);
|
|
8301
9055
|
|
|
8302
9056
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
8303
9057
|
// Both produce ContextInjection[] that get merged before inference.
|
|
@@ -8336,6 +9090,15 @@ export class AgentFramework {
|
|
|
8336
9090
|
}
|
|
8337
9091
|
}
|
|
8338
9092
|
|
|
9093
|
+
if (trigger?.ephemeralSystemPrompt) {
|
|
9094
|
+
const silentInjection: ContextInjection = {
|
|
9095
|
+
namespace: 'framework:silent-heartbeat',
|
|
9096
|
+
position: 'system',
|
|
9097
|
+
content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
|
|
9098
|
+
};
|
|
9099
|
+
injections = injections ? [...injections, silentInjection] : [silentInjection];
|
|
9100
|
+
}
|
|
9101
|
+
|
|
8339
9102
|
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
8340
9103
|
// Do not create a provider stream after its generation lost ownership.
|
|
8341
9104
|
if (this.agents.get(agent.name) !== agent) {
|
|
@@ -8466,6 +9229,7 @@ export class AgentFramework {
|
|
|
8466
9229
|
drainKvSubmissionIds?: () => string[],
|
|
8467
9230
|
knownToolNames: ReadonlySet<string> = new Set(),
|
|
8468
9231
|
): Promise<void> {
|
|
9232
|
+
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
8469
9233
|
const startTime = Date.now();
|
|
8470
9234
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8471
9235
|
const myStreamId = agent.streamId;
|
|
@@ -8523,7 +9287,10 @@ export class AgentFramework {
|
|
|
8523
9287
|
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
8524
9288
|
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
8525
9289
|
// clears it, because the following prose is a reply to a new message.
|
|
8526
|
-
let turnSilenced =
|
|
9290
|
+
let turnSilenced = trigger?.suppressProse === true
|
|
9291
|
+
|| (trigger?.reason === 'tool_result_guard_retry'
|
|
9292
|
+
&& this.guardRetryTurnSilenced.get(agent.name) === true);
|
|
9293
|
+
this.guardRetryTurnSilenced.delete(agent.name);
|
|
8527
9294
|
|
|
8528
9295
|
// Live routing is only trusted when the membrane provides verbatim
|
|
8529
9296
|
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
@@ -8546,9 +9313,9 @@ export class AgentFramework {
|
|
|
8546
9313
|
// sent here", which is true regardless of where the reply goes.
|
|
8547
9314
|
// Heartbeat/no-trigger explicit turns show no indicator.
|
|
8548
9315
|
const typingChannel =
|
|
8549
|
-
|
|
9316
|
+
turnProseRouting === 'disabled'
|
|
8550
9317
|
? null
|
|
8551
|
-
:
|
|
9318
|
+
: turnProseRouting === 'explicit'
|
|
8552
9319
|
? trigger?.channelId ?? null
|
|
8553
9320
|
: resolveTurnLocus();
|
|
8554
9321
|
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
@@ -8566,6 +9333,9 @@ export class AgentFramework {
|
|
|
8566
9333
|
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
8567
9334
|
// finalized) instead of being appended to — see the 'retrying' case.
|
|
8568
9335
|
let outgoingInferenceId = newOutgoingInferenceId();
|
|
9336
|
+
// RFC-007: every inference id THIS stream mints, so the stream's end
|
|
9337
|
+
// aborts only its own open tool calls — never a successor's.
|
|
9338
|
+
const lifecycleInferenceIds = new Set<string>([outgoingInferenceId]);
|
|
8569
9339
|
let outgoingIndex = 0;
|
|
8570
9340
|
|
|
8571
9341
|
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
@@ -8586,10 +9356,10 @@ export class AgentFramework {
|
|
|
8586
9356
|
// turns until completion: otherwise speculative outgoing chunks could
|
|
8587
9357
|
// expose the wrapper before the fail-closed classifier runs.
|
|
8588
9358
|
const proseStream = this.channelRegistry &&
|
|
8589
|
-
|
|
9359
|
+
turnProseRouting !== 'disabled' &&
|
|
8590
9360
|
!agent.toolWrapperProseGuard
|
|
8591
9361
|
? new ProseStreamRouter({
|
|
8592
|
-
mode:
|
|
9362
|
+
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
8593
9363
|
initialTarget: typingChannel,
|
|
8594
9364
|
resolve: (spec) => {
|
|
8595
9365
|
const r = this.channelRegistry!.resolveProseTarget(spec);
|
|
@@ -8605,6 +9375,23 @@ export class AgentFramework {
|
|
|
8605
9375
|
}
|
|
8606
9376
|
};
|
|
8607
9377
|
|
|
9378
|
+
// Tool-result-guard publication boundary. While a SUBMITTED batch is
|
|
9379
|
+
// pending (or a guard recovery is running), a refusal can still abandon
|
|
9380
|
+
// this physical round, so its streamed text must not reach any surface
|
|
9381
|
+
// yet — neither the prose router nor the inference:tokens trace (TTS and
|
|
9382
|
+
// other trace consumers voice it). Hold both; release in order on a clean
|
|
9383
|
+
// round (tool-calls / non-guard completion), discard on a guard refusal.
|
|
9384
|
+
let guardHeld: Array<{ trace: Parameters<AgentFramework['emitTrace']>[0]; text?: string }> = [];
|
|
9385
|
+
const releaseGuardHeld = (): void => {
|
|
9386
|
+
const held = guardHeld;
|
|
9387
|
+
guardHeld = [];
|
|
9388
|
+
for (const item of held) {
|
|
9389
|
+
this.emitTrace(item.trace);
|
|
9390
|
+
if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text));
|
|
9391
|
+
}
|
|
9392
|
+
};
|
|
9393
|
+
const discardGuardHeld = (): void => { guardHeld = []; };
|
|
9394
|
+
|
|
8608
9395
|
const adoptInjectedRound = (): void => {
|
|
8609
9396
|
if (!this.midTurnInputSignals.has(agent.name)) return;
|
|
8610
9397
|
this.midTurnInputSignals.delete(agent.name);
|
|
@@ -8612,7 +9399,7 @@ export class AgentFramework {
|
|
|
8612
9399
|
// A new conversational message, not merely a tool result, means any
|
|
8613
9400
|
// earlier explicit delivery has completed its conversational job.
|
|
8614
9401
|
// Routing is NOT touched: the turn locus stays frozen.
|
|
8615
|
-
turnSilenced =
|
|
9402
|
+
turnSilenced = trigger?.suppressProse === true;
|
|
8616
9403
|
};
|
|
8617
9404
|
|
|
8618
9405
|
try {
|
|
@@ -8627,8 +9414,8 @@ export class AgentFramework {
|
|
|
8627
9414
|
}
|
|
8628
9415
|
this.touchEphemeralRun(agent.name, true);
|
|
8629
9416
|
switch (event.type) {
|
|
8630
|
-
case 'tokens':
|
|
8631
|
-
|
|
9417
|
+
case 'tokens': {
|
|
9418
|
+
const trace: Parameters<AgentFramework['emitTrace']>[0] = {
|
|
8632
9419
|
type: 'inference:tokens',
|
|
8633
9420
|
agentName: agent.name,
|
|
8634
9421
|
content: event.content,
|
|
@@ -8638,11 +9425,16 @@ export class AgentFramework {
|
|
|
8638
9425
|
// lets trace consumers tag every chunk with the channel this
|
|
8639
9426
|
// turn's prose is bound for, without re-deriving routing.
|
|
8640
9427
|
channelId: typingChannel ?? undefined,
|
|
8641
|
-
}
|
|
8642
|
-
|
|
8643
|
-
|
|
9428
|
+
};
|
|
9429
|
+
const text = event.meta.type === 'text' ? event.content : undefined;
|
|
9430
|
+
if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
|
|
9431
|
+
guardHeld.push({ trace, text });
|
|
9432
|
+
} else {
|
|
9433
|
+
this.emitTrace(trace);
|
|
9434
|
+
if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text));
|
|
8644
9435
|
}
|
|
8645
9436
|
break;
|
|
9437
|
+
}
|
|
8646
9438
|
|
|
8647
9439
|
case 'retrying': {
|
|
8648
9440
|
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
@@ -8663,7 +9455,9 @@ export class AgentFramework {
|
|
|
8663
9455
|
' — discarding the refused attempt',
|
|
8664
9456
|
);
|
|
8665
9457
|
outgoingInferenceId = newOutgoingInferenceId();
|
|
9458
|
+
lifecycleInferenceIds.add(outgoingInferenceId);
|
|
8666
9459
|
outgoingIndex = 0;
|
|
9460
|
+
discardGuardHeld();
|
|
8667
9461
|
proseStream?.reset();
|
|
8668
9462
|
this.emitTrace({
|
|
8669
9463
|
type: 'inference:tokens',
|
|
@@ -8690,6 +9484,10 @@ export class AgentFramework {
|
|
|
8690
9484
|
}
|
|
8691
9485
|
|
|
8692
9486
|
case 'tool-calls': {
|
|
9487
|
+
// A tool-call response is a clean physical round: admit its
|
|
9488
|
+
// preceding results before storing/dispatching this new round.
|
|
9489
|
+
agent.toolResultGuard.accept();
|
|
9490
|
+
releaseGuardHeld();
|
|
8693
9491
|
adoptInjectedRound();
|
|
8694
9492
|
hadToolCalls = true;
|
|
8695
9493
|
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
@@ -8761,7 +9559,14 @@ export class AgentFramework {
|
|
|
8761
9559
|
agent.enterWaitingForTools(event.calls, stream);
|
|
8762
9560
|
|
|
8763
9561
|
for (const call of event.calls) {
|
|
9562
|
+
// RFC-007: register at the one dispatch point for model-issued
|
|
9563
|
+
// calls, keyed by (agent, call id) with this inference's id;
|
|
9564
|
+
// `started` goes out once dispatch returns. Refusal sites inside
|
|
9565
|
+
// dispatch run synchronously and call refuse() first, so a call
|
|
9566
|
+
// the host refuses produces no events.
|
|
9567
|
+
this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
|
|
8764
9568
|
this.dispatchToolCall(agent.name, call);
|
|
9569
|
+
this.toolLifecycleEmitter?.open(agent.name, call.id);
|
|
8765
9570
|
}
|
|
8766
9571
|
|
|
8767
9572
|
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
@@ -8787,12 +9592,12 @@ export class AgentFramework {
|
|
|
8787
9592
|
liveProseRouting = true;
|
|
8788
9593
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
8789
9594
|
if (roundSegments.length > 0) {
|
|
8790
|
-
if (
|
|
9595
|
+
if (turnProseRouting === 'disabled') {
|
|
8791
9596
|
console.error(
|
|
8792
9597
|
`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
|
|
8793
9598
|
);
|
|
8794
9599
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
8795
|
-
} else if (
|
|
9600
|
+
} else if (turnProseRouting === 'hybrid') {
|
|
8796
9601
|
if (turnSilenced) {
|
|
8797
9602
|
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
8798
9603
|
} else if (!hasSameRoundPrivateThink) {
|
|
@@ -8803,7 +9608,7 @@ export class AgentFramework {
|
|
|
8803
9608
|
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
8804
9609
|
}
|
|
8805
9610
|
}
|
|
8806
|
-
} else if (
|
|
9611
|
+
} else if (turnProseRouting === 'explicit') {
|
|
8807
9612
|
// Explicit mode: every segment through the prose gateway.
|
|
8808
9613
|
// Silencing does not apply — unprefixed prose bounces and
|
|
8809
9614
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -8847,7 +9652,86 @@ export class AgentFramework {
|
|
|
8847
9652
|
case 'complete': {
|
|
8848
9653
|
adoptInjectedRound();
|
|
8849
9654
|
const durationMs = Date.now() - startTime;
|
|
8850
|
-
|
|
9655
|
+
let response = event.response;
|
|
9656
|
+
const guardCategory = (response.raw?.response as {
|
|
9657
|
+
stop_details?: { category?: string };
|
|
9658
|
+
} | undefined)?.stop_details?.category ?? 'unknown';
|
|
9659
|
+
// A staged batch the strategy omitted from the request cannot
|
|
9660
|
+
// have caused this refusal: settle it and fall through to the
|
|
9661
|
+
// ordinary refusal handling (rewind/reaction) below.
|
|
9662
|
+
if (response.stopReason === 'refusal') agent.toolResultGuard.settleUnsubmitted(guardCategory);
|
|
9663
|
+
const guardRefusal = response.stopReason === 'refusal'
|
|
9664
|
+
&& (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
|
|
9665
|
+
if (guardRefusal) {
|
|
9666
|
+
discardGuardHeld();
|
|
9667
|
+
const category = guardCategory;
|
|
9668
|
+
const withheld = agent.toolResultGuard.withhold(category);
|
|
9669
|
+
// Nothing from the abandoned physical attempt is published or
|
|
9670
|
+
// persisted as assistant speech, including a terminal refusal
|
|
9671
|
+
// after the single recovery retry.
|
|
9672
|
+
proseStream?.reset();
|
|
9673
|
+
if (withheld) {
|
|
9674
|
+
const usage = response.details?.usage ?? response.usage;
|
|
9675
|
+
const tokenUsage = usage ? {
|
|
9676
|
+
input: usage.inputTokens, output: usage.outputTokens,
|
|
9677
|
+
cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
|
|
9678
|
+
} : undefined;
|
|
9679
|
+
this.noteRefusal(agent.name, category, tokenUsage);
|
|
9680
|
+
// The abandoned stream was billed (membrane usage here is
|
|
9681
|
+
// cumulative across this physical tool loop): count it before
|
|
9682
|
+
// the retry opens a fresh stream with its own usage.
|
|
9683
|
+
const abandonedUsage = response.details?.usage;
|
|
9684
|
+
if (abandonedUsage) {
|
|
9685
|
+
this.usageTracker.onInferenceCompleted(agent.name, {
|
|
9686
|
+
inputTokens: abandonedUsage.inputTokens,
|
|
9687
|
+
outputTokens: abandonedUsage.outputTokens,
|
|
9688
|
+
cacheCreationTokens: abandonedUsage.cacheCreationTokens,
|
|
9689
|
+
cacheReadTokens: abandonedUsage.cacheReadTokens,
|
|
9690
|
+
}, abandonedUsage.estimatedCost
|
|
9691
|
+
? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
|
|
9692
|
+
: undefined);
|
|
9693
|
+
this.persistUsageState();
|
|
9694
|
+
}
|
|
9695
|
+
this.logInference({
|
|
9696
|
+
timestamp: startTime, agentName: agent.name, requestId,
|
|
9697
|
+
success: false, error: 'Tool output withheld by the guard',
|
|
9698
|
+
request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
|
|
9699
|
+
});
|
|
9700
|
+
console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
|
|
9701
|
+
this.emitTrace({
|
|
9702
|
+
type: 'inference:stream_restarted', agentName: agent.name,
|
|
9703
|
+
reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
|
|
9704
|
+
budget: agent.maxStreamTokens,
|
|
9705
|
+
});
|
|
9706
|
+
await turnSpeechChain;
|
|
9707
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
9708
|
+
generationLost = true;
|
|
9709
|
+
lifecyclePhase = 'aborted';
|
|
9710
|
+
return;
|
|
9711
|
+
}
|
|
9712
|
+
agent.reset();
|
|
9713
|
+
preserveEventGateForSuccessor = true;
|
|
9714
|
+
lifecyclePhase = 'aborted';
|
|
9715
|
+
// Carry same-turn explicit-send suppression into the retry:
|
|
9716
|
+
// a text-only recovery after a successful send must not post
|
|
9717
|
+
// a postscript to a message already delivered.
|
|
9718
|
+
if (turnSilenced) this.guardRetryTurnSilenced.set(agent.name, true);
|
|
9719
|
+
// Restart inference inside this logical turn. No tool is
|
|
9720
|
+
// executed again and no settle/checkpoint/locus reset occurs.
|
|
9721
|
+
await this.startAgentStream(agent, {
|
|
9722
|
+
...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
|
|
9723
|
+
source: trigger?.source ?? 'framework', timestamp: Date.now(),
|
|
9724
|
+
}, attempt);
|
|
9725
|
+
return;
|
|
9726
|
+
}
|
|
9727
|
+
const lastResult = response.content.reduce((last, block, index) =>
|
|
9728
|
+
block.type === 'tool_result' ? index : last, -1);
|
|
9729
|
+
response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
|
|
9730
|
+
agent.toolResultGuard.recovering = false;
|
|
9731
|
+
} else if (response.stopReason !== 'refusal') {
|
|
9732
|
+
agent.toolResultGuard.accept();
|
|
9733
|
+
}
|
|
9734
|
+
if (!guardRefusal) releaseGuardHeld();
|
|
8851
9735
|
|
|
8852
9736
|
// If the agent is still waiting_for_tools when 'complete' fires
|
|
8853
9737
|
// (shouldn't happen after incomplete-tool-call fix, but guard anyway),
|
|
@@ -8876,12 +9760,17 @@ export class AgentFramework {
|
|
|
8876
9760
|
// one door a giant blob still walks through).
|
|
8877
9761
|
const readyState = agent.state as AgentState;
|
|
8878
9762
|
if (readyState.status === 'ready') {
|
|
8879
|
-
const
|
|
9763
|
+
const cap = this.resolveToolResultInlineCap(agent).cap;
|
|
9764
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(
|
|
8880
9765
|
agent.name,
|
|
8881
9766
|
readyState.toolResults,
|
|
8882
|
-
|
|
9767
|
+
cap,
|
|
8883
9768
|
);
|
|
8884
|
-
agent.
|
|
9769
|
+
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
|
|
9770
|
+
this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
|
|
9771
|
+
// The response is already complete: this batch is never
|
|
9772
|
+
// submitted in this turn, so it cannot be refused. Admit it.
|
|
9773
|
+
agent.toolResultGuard.settleTurnEnded();
|
|
8885
9774
|
}
|
|
8886
9775
|
}
|
|
8887
9776
|
|
|
@@ -9097,7 +9986,7 @@ export class AgentFramework {
|
|
|
9097
9986
|
},
|
|
9098
9987
|
() => this.finishUnstick(agent.name, false, category),
|
|
9099
9988
|
);
|
|
9100
|
-
} else if (rh?.autoRewind) {
|
|
9989
|
+
} else if (rh?.autoRewind && !guardRefusal) {
|
|
9101
9990
|
const cap = Math.max(1, rh.maxRewinds ?? 3);
|
|
9102
9991
|
const used = this.refusalRewinds.get(agent.name) ?? 0;
|
|
9103
9992
|
doRewind(
|
|
@@ -9145,7 +10034,7 @@ export class AgentFramework {
|
|
|
9145
10034
|
}
|
|
9146
10035
|
|
|
9147
10036
|
// Dispatch speech (and thoughts if any)
|
|
9148
|
-
if (speechContent.length > 0 || thoughts.length > 0) {
|
|
10037
|
+
if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
|
|
9149
10038
|
const speechContext: SpeechContext = {
|
|
9150
10039
|
turnComplete: true,
|
|
9151
10040
|
trigger: trigger ?? {
|
|
@@ -9176,10 +10065,17 @@ export class AgentFramework {
|
|
|
9176
10065
|
.join('\n')
|
|
9177
10066
|
.trim();
|
|
9178
10067
|
if (speechText) {
|
|
9179
|
-
if (
|
|
10068
|
+
if (turnProseRouting === 'disabled') {
|
|
9180
10069
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
9181
10070
|
this.recordProseSuppression(agent.name, 1);
|
|
9182
|
-
} else if (
|
|
10071
|
+
} else if (turnSilenced && turnProseRouting !== 'explicit') {
|
|
10072
|
+
// Only reachable on a guard recovery that carried the
|
|
10073
|
+
// same-turn send suppression, or a suppressProse trigger
|
|
10074
|
+
// (a fresh text-only turn starts unsilenced). Explicit
|
|
10075
|
+
// mode is exempt, as mid-turn.
|
|
10076
|
+
console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
|
|
10077
|
+
this.recordProseSuppression(agent.name, 1);
|
|
10078
|
+
} else if (turnProseRouting === 'hybrid') {
|
|
9183
10079
|
const locus = resolveTurnLocus();
|
|
9184
10080
|
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
9185
10081
|
try {
|
|
@@ -9187,7 +10083,7 @@ export class AgentFramework {
|
|
|
9187
10083
|
} catch (err) {
|
|
9188
10084
|
console.error('text-only hybrid prose delivery failed:', err);
|
|
9189
10085
|
}
|
|
9190
|
-
} else if (
|
|
10086
|
+
} else if (turnProseRouting === 'explicit') {
|
|
9191
10087
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
9192
10088
|
try {
|
|
9193
10089
|
await this.deliverProse(agent, speechText);
|
|
@@ -9243,14 +10139,14 @@ export class AgentFramework {
|
|
|
9243
10139
|
// the chain may still be flushing earlier rounds' posts.
|
|
9244
10140
|
await turnSpeechChain;
|
|
9245
10141
|
|
|
9246
|
-
if (
|
|
10142
|
+
if (turnProseRouting === 'disabled') {
|
|
9247
10143
|
if (segments.length > 0) {
|
|
9248
10144
|
console.error(
|
|
9249
10145
|
`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
|
|
9250
10146
|
);
|
|
9251
10147
|
this.recordProseSuppression(agent.name, segments.length);
|
|
9252
10148
|
}
|
|
9253
|
-
} else if (
|
|
10149
|
+
} else if (turnProseRouting === 'hybrid') {
|
|
9254
10150
|
if (silenced && segments.length > 0) {
|
|
9255
10151
|
this.recordProseSuppression(agent.name, segments.length);
|
|
9256
10152
|
} else if (segments.length > 0) {
|
|
@@ -9263,7 +10159,7 @@ export class AgentFramework {
|
|
|
9263
10159
|
}
|
|
9264
10160
|
}
|
|
9265
10161
|
}
|
|
9266
|
-
} else if (
|
|
10162
|
+
} else if (turnProseRouting === 'explicit') {
|
|
9267
10163
|
if (segments.length > 0) {
|
|
9268
10164
|
console.error(
|
|
9269
10165
|
`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
|
|
@@ -9316,7 +10212,7 @@ export class AgentFramework {
|
|
|
9316
10212
|
// awaited before trailing dispatch, and trailing/text-only
|
|
9317
10213
|
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
9318
10214
|
// envelopes acknowledge themselves through the prose gateway.
|
|
9319
|
-
if (
|
|
10215
|
+
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
9320
10216
|
this.appendProseDeliveryReceipt(agent);
|
|
9321
10217
|
}
|
|
9322
10218
|
|
|
@@ -9485,7 +10381,7 @@ export class AgentFramework {
|
|
|
9485
10381
|
// deliveries map persists (cleared only at fresh turn
|
|
9486
10382
|
// start) and the continuation stream's end writes ONE
|
|
9487
10383
|
// receipt for the whole turn.
|
|
9488
|
-
if (cancelKind === 'turn_ended' &&
|
|
10384
|
+
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
9489
10385
|
await turnSpeechChain;
|
|
9490
10386
|
this.appendProseDeliveryReceipt(agent);
|
|
9491
10387
|
}
|
|
@@ -9705,6 +10601,13 @@ export class AgentFramework {
|
|
|
9705
10601
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
9706
10602
|
// crash emits nothing — that is the documented best-effort limit, and
|
|
9707
10603
|
// why consumers keep their safety timeout.
|
|
10604
|
+
//
|
|
10605
|
+
// RFC-007 §7.3: a tool call still open when its turn's stream ends was
|
|
10606
|
+
// cancelled before a result — its terminal is `aborted`. (A normal turn
|
|
10607
|
+
// has none: the stream waits for every result before continuing.)
|
|
10608
|
+
// Scoped to this stream's own inference ids: a budget-restart successor
|
|
10609
|
+
// may already be running for the same agent.
|
|
10610
|
+
this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
|
|
9708
10611
|
this.hookOrchestrator?.emitLifecycle({
|
|
9709
10612
|
inferenceId: outgoingInferenceId,
|
|
9710
10613
|
conversationId: agent.name,
|
|
@@ -9719,6 +10622,22 @@ export class AgentFramework {
|
|
|
9719
10622
|
// (typing still stops, compression still runs — matching the observed
|
|
9720
10623
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
9721
10624
|
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
10625
|
+
// A frame that ends without handing off to a successor (abort,
|
|
10626
|
+
// exhausted errors) must not leave its batch pending: a later turn
|
|
10627
|
+
// would resubmit the originals and claim its own refusal. Successor
|
|
10628
|
+
// frames (error-policy retry, budget/guard restart) bump streamId or
|
|
10629
|
+
// set preserveEventGateForSuccessor and keep the batch.
|
|
10630
|
+
if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
|
|
10631
|
+
// abandon() clears the batch before its audit append, so a failed
|
|
10632
|
+
// write cannot re-arm it; it must not abort the rest of teardown
|
|
10633
|
+
// (gate release, turn token, provider admission) either.
|
|
10634
|
+
try {
|
|
10635
|
+
agent.toolResultGuard.abandon('aborted');
|
|
10636
|
+
} catch (error) {
|
|
10637
|
+
console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
|
|
10638
|
+
}
|
|
10639
|
+
}
|
|
10640
|
+
agent.toolResultGuard.recovering = false;
|
|
9722
10641
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
9723
10642
|
}
|
|
9724
10643
|
if (!generationLost && ownsPhysicalStream) {
|
|
@@ -10061,6 +10980,7 @@ export class AgentFramework {
|
|
|
10061
10980
|
const startTime = Date.now();
|
|
10062
10981
|
this.runCodeExecution(agentName, call)
|
|
10063
10982
|
.catch((error): ToolResult => {
|
|
10983
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
10064
10984
|
// runCodeExecution is designed not to reject; this is the last-resort
|
|
10065
10985
|
// guard so a bug here can never strand the agent in waiting_for_tools.
|
|
10066
10986
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -10110,6 +11030,7 @@ export class AgentFramework {
|
|
|
10110
11030
|
background?: unknown;
|
|
10111
11031
|
action?: unknown;
|
|
10112
11032
|
script_id?: unknown;
|
|
11033
|
+
time_limit_ms?: unknown;
|
|
10113
11034
|
};
|
|
10114
11035
|
|
|
10115
11036
|
// Management surface: the agent's own daemon fleet is inspectable and
|
|
@@ -10158,6 +11079,18 @@ export class AgentFramework {
|
|
|
10158
11079
|
};
|
|
10159
11080
|
}
|
|
10160
11081
|
|
|
11082
|
+
// A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
|
|
11083
|
+
if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
|
|
11084
|
+
return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
|
|
11085
|
+
}
|
|
11086
|
+
const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
|
|
11087
|
+
const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
|
|
11088
|
+
const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
|
|
11089
|
+
const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
|
|
11090
|
+
const capNote = requestedMs !== undefined && requestedMs > ceilingMs
|
|
11091
|
+
? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
|
|
11092
|
+
: undefined;
|
|
11093
|
+
|
|
10161
11094
|
const agent = this.agents.get(agentName);
|
|
10162
11095
|
const surface = agent
|
|
10163
11096
|
? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
|
|
@@ -10167,17 +11100,26 @@ export class AgentFramework {
|
|
|
10167
11100
|
);
|
|
10168
11101
|
|
|
10169
11102
|
if (input.background === true) {
|
|
10170
|
-
|
|
11103
|
+
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
|
|
11104
|
+
if (capNote && started.success && started.data && typeof started.data === 'object') {
|
|
11105
|
+
(started.data as Record<string, unknown>).time_limit_note = capNote;
|
|
11106
|
+
}
|
|
11107
|
+
return started;
|
|
10171
11108
|
}
|
|
10172
11109
|
|
|
10173
11110
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
10174
11111
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
10175
|
-
const exec = await runner.exec(input.code, injected);
|
|
11112
|
+
const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
|
|
10176
11113
|
const endTurn = this.scriptDeferredEndTurn.delete(agentName);
|
|
10177
11114
|
|
|
10178
11115
|
return {
|
|
10179
11116
|
success: true,
|
|
10180
|
-
data: {
|
|
11117
|
+
data: {
|
|
11118
|
+
stdout: exec.stdout,
|
|
11119
|
+
stderr: exec.stderr,
|
|
11120
|
+
return_code: exec.returnCode,
|
|
11121
|
+
...(capNote ? { time_limit_note: capNote } : {}),
|
|
11122
|
+
},
|
|
10181
11123
|
isError: false,
|
|
10182
11124
|
...(endTurn ? { endTurn: true } : {}),
|
|
10183
11125
|
};
|
|
@@ -10199,6 +11141,7 @@ export class AgentFramework {
|
|
|
10199
11141
|
agentName: string,
|
|
10200
11142
|
code: string,
|
|
10201
11143
|
injected: import('./code-execution/py-runner.js').InjectedTool[],
|
|
11144
|
+
timeLimitMs?: number,
|
|
10202
11145
|
): ToolResult {
|
|
10203
11146
|
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
10204
11147
|
// would outlive its owner and its wake would land in the primary
|
|
@@ -10224,7 +11167,8 @@ export class AgentFramework {
|
|
|
10224
11167
|
}
|
|
10225
11168
|
|
|
10226
11169
|
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
10227
|
-
|
|
11170
|
+
// The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
|
|
11171
|
+
const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
10228
11172
|
|
|
10229
11173
|
// Journal: a file under the agent's first read-write workspace mount so
|
|
10230
11174
|
// their existing read/grep/shell tools work on it. Python appends
|
|
@@ -10290,6 +11234,7 @@ export class AgentFramework {
|
|
|
10290
11234
|
script_id: scriptId,
|
|
10291
11235
|
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
10292
11236
|
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
11237
|
+
lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
|
|
10293
11238
|
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
10294
11239
|
},
|
|
10295
11240
|
};
|
|
@@ -10926,17 +11871,91 @@ export class AgentFramework {
|
|
|
10926
11871
|
this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
|
|
10927
11872
|
}
|
|
10928
11873
|
|
|
11874
|
+
/** Forget one server's RFC-008 class declarations (see the close handler). */
|
|
11875
|
+
private dropMcplToolClasses(serverId: string): void {
|
|
11876
|
+
// Tolerate partially-constructed frameworks (tests build them with
|
|
11877
|
+
// Object.create): nothing cached means nothing to forget.
|
|
11878
|
+
if (!this.mcplToolClasses?.size) return;
|
|
11879
|
+
const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
|
|
11880
|
+
for (const name of [...this.mcplToolClasses.keys()]) {
|
|
11881
|
+
if (name.startsWith(`${prefix}--`)) this.mcplToolClasses.delete(name);
|
|
11882
|
+
}
|
|
11883
|
+
}
|
|
11884
|
+
|
|
11885
|
+
/**
|
|
11886
|
+
* RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
|
|
11887
|
+
* its effective class, and for MCPL tools the providing connection and
|
|
11888
|
+
* the server's own tool name. Host knowledge (built-in table, embedding
|
|
11889
|
+
* host's table) applies only to tools the host implements; an MCPL tool's
|
|
11890
|
+
* class comes from an operator override or its server's declaration.
|
|
11891
|
+
*/
|
|
11892
|
+
private describeToolForLifecycle(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string } {
|
|
11893
|
+
const mcpl = this.resolveMcplTool(tool);
|
|
11894
|
+
const { classes } = this.effectiveToolClass(tool, mcpl !== null);
|
|
11895
|
+
if (mcpl) return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
|
|
11896
|
+
return { class: classes };
|
|
11897
|
+
}
|
|
11898
|
+
|
|
11899
|
+
/**
|
|
11900
|
+
* RFC-008 §5.1: a tool's effective class and the source that decided it.
|
|
11901
|
+
* Operator overrides first; then host knowledge, for tools the host
|
|
11902
|
+
* implements (never for MCPL tools); then an MCPL tool's own declaration.
|
|
11903
|
+
* Tolerates partially-constructed frameworks (tests use Object.create).
|
|
11904
|
+
*/
|
|
11905
|
+
private effectiveToolClass(tool: string, isMcpl: boolean): EffectiveToolClass {
|
|
11906
|
+
return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
|
|
11907
|
+
overrides: this.toolClassOverrides ?? [],
|
|
11908
|
+
host: isMcpl ? [] : (this.hostToolClasses ?? []),
|
|
11909
|
+
});
|
|
11910
|
+
}
|
|
11911
|
+
|
|
11912
|
+
/**
|
|
11913
|
+
* RFC-008 §6: tools with their effective class and where that class came
|
|
11914
|
+
* from (`override`, `host`, `server`, or `none` for an unclassed tool,
|
|
11915
|
+
* which policy treats as most restrictive). For operators: a surprising
|
|
11916
|
+
* class should be visible before it matters.
|
|
11917
|
+
*
|
|
11918
|
+
* With `agentName`, exactly the tools that agent is shown. Without, every
|
|
11919
|
+
* tool the framework offers to anyone: the shared board plus each agent's
|
|
11920
|
+
* own (the subconscious's surface, `prose_help`), each listed once.
|
|
11921
|
+
*/
|
|
11922
|
+
listToolClasses(agentName?: string): Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> {
|
|
11923
|
+
let tools: import('./types/index.js').ToolDefinition[];
|
|
11924
|
+
if (agentName !== undefined) {
|
|
11925
|
+
const agent = this.agents.get(agentName);
|
|
11926
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
11927
|
+
tools = this.agentToolSurface(agent);
|
|
11928
|
+
} else {
|
|
11929
|
+
tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
|
|
11930
|
+
}
|
|
11931
|
+
const seen = new Set<string>();
|
|
11932
|
+
const listed: Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> = [];
|
|
11933
|
+
for (const t of tools) {
|
|
11934
|
+
if (seen.has(t.name)) continue;
|
|
11935
|
+
seen.add(t.name);
|
|
11936
|
+
const mcpl = this.resolveMcplTool(t.name);
|
|
11937
|
+
const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
|
|
11938
|
+
listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
|
|
11939
|
+
}
|
|
11940
|
+
return listed;
|
|
11941
|
+
}
|
|
11942
|
+
|
|
10929
11943
|
/**
|
|
10930
|
-
* Find the MCPL server for a tool
|
|
10931
|
-
*
|
|
11944
|
+
* Find the MCPL server for a tool: [serverId, prefix], or null.
|
|
11945
|
+
* Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
|
|
11946
|
+
* both. The server whose tools/list produced the name decides; a name no
|
|
11947
|
+
* live server listed (stale, or invented by the model) goes to the longest
|
|
11948
|
+
* matching prefix. Never prefix-map insertion order.
|
|
10932
11949
|
*/
|
|
10933
11950
|
private resolveMcplTool(toolName: string): [string, string] | null {
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
11951
|
+
const listedBy = this.mcplToolServers?.get(toolName);
|
|
11952
|
+
let best: [string, string] | null = null;
|
|
11953
|
+
for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
|
|
11954
|
+
if (!toolName.startsWith(prefix + '--')) continue;
|
|
11955
|
+
if (serverId === listedBy) return [serverId, prefix];
|
|
11956
|
+
if (!best || prefix.length > best[1].length) best = [serverId, prefix];
|
|
10938
11957
|
}
|
|
10939
|
-
return
|
|
11958
|
+
return best;
|
|
10940
11959
|
}
|
|
10941
11960
|
|
|
10942
11961
|
private dispatchToolCall(agentName: string, call: ToolCall): void {
|
|
@@ -11030,11 +12049,11 @@ export class AgentFramework {
|
|
|
11030
12049
|
return;
|
|
11031
12050
|
}
|
|
11032
12051
|
|
|
11033
|
-
// Route synthesized 'think' (private reasoning)
|
|
11034
|
-
// stay-silent) tools — handled by the channel registry like the other
|
|
12052
|
+
// Route synthesized 'think' (private reasoning), 'journal' (private
|
|
12053
|
+
// long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other
|
|
11035
12054
|
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
11036
12055
|
// need an explicit route here.
|
|
11037
|
-
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
12056
|
+
if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
11038
12057
|
// skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
|
|
11039
12058
|
// NOW" can also mean "back in a moment" — ends the turn, then wakes
|
|
11040
12059
|
// the agent after N seconds unless something else wakes it first
|
|
@@ -11130,6 +12149,8 @@ export class AgentFramework {
|
|
|
11130
12149
|
})
|
|
11131
12150
|
.catch((error) => {
|
|
11132
12151
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
12152
|
+
// RFC-007: the module threw — the host could not obtain a result.
|
|
12153
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
11133
12154
|
this.emitTrace({
|
|
11134
12155
|
type: 'tool:failed',
|
|
11135
12156
|
module: moduleName,
|
|
@@ -11174,6 +12195,14 @@ export class AgentFramework {
|
|
|
11174
12195
|
* minting a second, independently replayable one.
|
|
11175
12196
|
*/
|
|
11176
12197
|
deferredWriteId?: string;
|
|
12198
|
+
/** Out-param: where the message landed (RFC-006 needs to find it again). */
|
|
12199
|
+
placement?: { agent?: string; messageId?: MessageId; deferredId?: string };
|
|
12200
|
+
/**
|
|
12201
|
+
* RFC-006 assembly: the named agent's turn is alive but its compile has
|
|
12202
|
+
* not run yet (the same window the turn-start deferred flush writes
|
|
12203
|
+
* in). A message for THAT agent is stored directly instead of deferred.
|
|
12204
|
+
*/
|
|
12205
|
+
bypassDeferralFor?: string;
|
|
11177
12206
|
}
|
|
11178
12207
|
): MessageId {
|
|
11179
12208
|
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
@@ -11223,6 +12252,7 @@ export class AgentFramework {
|
|
|
11223
12252
|
: this.pendingAssistantBlocks.size > 0;
|
|
11224
12253
|
if (
|
|
11225
12254
|
!hasToolResult &&
|
|
12255
|
+
opts?.bypassDeferralFor !== agent.name &&
|
|
11226
12256
|
(this.quiesced ||
|
|
11227
12257
|
midToolCycle ||
|
|
11228
12258
|
this.activeTurnTokens.has(agent.name) ||
|
|
@@ -11239,14 +12269,17 @@ export class AgentFramework {
|
|
|
11239
12269
|
}
|
|
11240
12270
|
this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
|
|
11241
12271
|
if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
|
|
12272
|
+
if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.deferredId = id; }
|
|
11242
12273
|
return '' as MessageId; // Deferred — flushed at the target's next boundary
|
|
11243
12274
|
}
|
|
11244
12275
|
|
|
11245
|
-
|
|
12276
|
+
const stored = agent.getContextManager().addMessage(
|
|
11246
12277
|
participant,
|
|
11247
12278
|
content,
|
|
11248
12279
|
opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata,
|
|
11249
12280
|
);
|
|
12281
|
+
if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
|
|
12282
|
+
return stored;
|
|
11250
12283
|
}
|
|
11251
12284
|
|
|
11252
12285
|
/**
|
|
@@ -11557,24 +12590,27 @@ export class AgentFramework {
|
|
|
11557
12590
|
* quarantine, OverBudget drain kick) gate on `errorType`, so classification
|
|
11558
12591
|
* drift between sites would silently disable a safety net.
|
|
11559
12592
|
*
|
|
11560
|
-
* context-manager's
|
|
11561
|
-
*
|
|
11562
|
-
*
|
|
11563
|
-
*
|
|
11564
|
-
*
|
|
12593
|
+
* context-manager's refusal errors are matched by a real `instanceof` since
|
|
12594
|
+
* CM exports them from its package root (context-manager#41 / #71; floor
|
|
12595
|
+
* `^0.7.0`, ours is higher). The `err.name` comparison stays as a FALLBACK
|
|
12596
|
+
* only: a deployment that ends up with two CM copies (a peer package
|
|
12597
|
+
* pinning a different version) constructs errors from a different class
|
|
12598
|
+
* object, and `name` — set in the constructor — survives that boundary.
|
|
12599
|
+
* Deliberately NOT a message match: the message wording belongs to CM and
|
|
12600
|
+
* can be reworded without warning.
|
|
11565
12601
|
*/
|
|
11566
12602
|
private classifyInferenceError(err: Error): { retryable?: boolean; errorType?: string } {
|
|
11567
12603
|
if (err instanceof MembraneError) {
|
|
11568
12604
|
return { retryable: err.retryable, errorType: err.type };
|
|
11569
12605
|
}
|
|
11570
|
-
if (err.name === 'OverBudgetError') {
|
|
12606
|
+
if (err instanceof OverBudgetError || err.name === 'OverBudgetError') {
|
|
11571
12607
|
return { errorType: 'over_budget' };
|
|
11572
12608
|
}
|
|
11573
12609
|
// context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
|
|
11574
12610
|
// 2026-07-26): a compile REFUSES rather than shipping a context with
|
|
11575
12611
|
// silently-dropped messages. Recovery is identical to over_budget — kick
|
|
11576
12612
|
// the compression drain so summaries cover the un-represented span.
|
|
11577
|
-
if (err.name === 'UncoveredDropError') {
|
|
12613
|
+
if (err instanceof UncoveredDropError || err.name === 'UncoveredDropError') {
|
|
11578
12614
|
return { errorType: 'context_refusal' };
|
|
11579
12615
|
}
|
|
11580
12616
|
return {};
|
|
@@ -11781,6 +12817,11 @@ export class AgentFramework {
|
|
|
11781
12817
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
11782
12818
|
this.featureSetManager = new FeatureSetManager();
|
|
11783
12819
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
12820
|
+
this.toolLifecycleEmitter = new ToolLifecycleEmitter({
|
|
12821
|
+
observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
|
|
12822
|
+
configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
|
|
12823
|
+
describe: (tool) => this.describeToolForLifecycle(tool),
|
|
12824
|
+
});
|
|
11784
12825
|
|
|
11785
12826
|
// Build prefix map and store configs for tool routing
|
|
11786
12827
|
for (const config of serverConfigs) {
|
|
@@ -11795,11 +12836,13 @@ export class AgentFramework {
|
|
|
11795
12836
|
?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
|
|
11796
12837
|
|
|
11797
12838
|
// Push events handler (Step 6)
|
|
12839
|
+
this.initializePushCoalescer();
|
|
11798
12840
|
this.pushHandler = new PushHandler(
|
|
11799
12841
|
this.featureSetManager,
|
|
11800
12842
|
(event) => this.pushEvent(event as unknown as ProcessEvent),
|
|
11801
12843
|
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
11802
12844
|
triggerFilter,
|
|
12845
|
+
(serverId, params, event) => this.handleCoalescedPush(serverId, params, event),
|
|
11803
12846
|
);
|
|
11804
12847
|
|
|
11805
12848
|
// Server-initiated inference router (Step 6)
|
|
@@ -11829,6 +12872,8 @@ export class AgentFramework {
|
|
|
11829
12872
|
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
11830
12873
|
{
|
|
11831
12874
|
store: this.store,
|
|
12875
|
+
handleCoalescedIncoming: (serverId, message, event) =>
|
|
12876
|
+
this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent),
|
|
11832
12877
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
11833
12878
|
const server = this.mcplServerRegistry!.getServer(serverId);
|
|
11834
12879
|
if (server) {
|
|
@@ -11903,11 +12948,17 @@ export class AgentFramework {
|
|
|
11903
12948
|
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
11904
12949
|
this.mcplHostCapabilities = {
|
|
11905
12950
|
version: '0.5',
|
|
12951
|
+
// RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
|
|
12952
|
+
// channel-scoped pushes, one-hour retry window.
|
|
12953
|
+
eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
|
|
11906
12954
|
pushEvents: true,
|
|
11907
12955
|
contextHooks: {
|
|
11908
12956
|
beforeInference: true,
|
|
11909
12957
|
},
|
|
11910
12958
|
inferenceLifecycle: true,
|
|
12959
|
+
// RFC-007: tools/lifecycle (metadata, and requested argument fields
|
|
12960
|
+
// under their own grant) plus the tools/observe filter.
|
|
12961
|
+
toolLifecycle: { observe: true, inputs: true },
|
|
11911
12962
|
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
11912
12963
|
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
11913
12964
|
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
@@ -12789,6 +13840,20 @@ export class AgentFramework {
|
|
|
12789
13840
|
deniedCapabilities: string[];
|
|
12790
13841
|
/** Host-owned authority; deliberately separate from the portable grant. */
|
|
12791
13842
|
allowHostCommands: boolean;
|
|
13843
|
+
/**
|
|
13844
|
+
* RFC-007 §10: the server's current tools/observe filter — its own
|
|
13845
|
+
* statement of which calls and argument fields it uses, shown next to
|
|
13846
|
+
* the grant so an operator can narrow toolLifecycle.inputs to match. It
|
|
13847
|
+
* confers nothing. Null = no filter (granted calls, metadata only).
|
|
13848
|
+
*/
|
|
13849
|
+
toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
|
|
13850
|
+
/**
|
|
13851
|
+
* RFC-008 §6: each of this server's tools with its effective class and
|
|
13852
|
+
* the source that decided it — `override` (operator), `server` (its own
|
|
13853
|
+
* `_meta["mcpl/class"]`), or `none` (unclassed: observers never see its
|
|
13854
|
+
* arguments). Shown next to the grant so a surprising class is visible.
|
|
13855
|
+
*/
|
|
13856
|
+
toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
|
|
12792
13857
|
/**
|
|
12793
13858
|
* Per-transport §17 facts about the last manifest this host fetched and
|
|
12794
13859
|
* acted on. The revision is server-authored and equality-only; these are
|
|
@@ -12807,6 +13872,8 @@ export class AgentFramework {
|
|
|
12807
13872
|
toolPrefix: string; toolCount: number; policyEstablished: boolean;
|
|
12808
13873
|
effectiveGrant: string[]; maskedCapabilities: string[];
|
|
12809
13874
|
deniedCapabilities: string[]; allowHostCommands: boolean;
|
|
13875
|
+
toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
|
|
13876
|
+
toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
|
|
12810
13877
|
manifestState: {
|
|
12811
13878
|
lastValidatedRevision: string | null;
|
|
12812
13879
|
lastFetchedAt: number | null;
|
|
@@ -12818,17 +13885,28 @@ export class AgentFramework {
|
|
|
12818
13885
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
12819
13886
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
12820
13887
|
const connected = connection?.isConnected ?? false;
|
|
13888
|
+
// Attribute through the dispatch resolver: a bare prefix match would
|
|
13889
|
+
// also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
|
|
13890
|
+
const ownTools = (this.mcplTools ?? []).flatMap((t) => {
|
|
13891
|
+
const hit = this.resolveMcplTool(t.name);
|
|
13892
|
+
return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
|
|
13893
|
+
});
|
|
12821
13894
|
result.push({
|
|
12822
13895
|
id,
|
|
12823
13896
|
connected,
|
|
12824
13897
|
retrying: !connected && (connection?.willReconnect ?? false),
|
|
12825
13898
|
toolPrefix: prefix,
|
|
12826
|
-
toolCount:
|
|
13899
|
+
toolCount: ownTools.length,
|
|
12827
13900
|
policyEstablished: connection?.policyEstablished ?? false,
|
|
12828
13901
|
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
12829
13902
|
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
12830
13903
|
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
12831
13904
|
allowHostCommands: config.allowHostCommands === true,
|
|
13905
|
+
toolObserveFilter: connection?.toolObserveFilter ?? null,
|
|
13906
|
+
toolClasses: ownTools.map(({ tool, serverTool }) => {
|
|
13907
|
+
const { classes, source } = this.effectiveToolClass(tool, true);
|
|
13908
|
+
return { tool, serverTool, class: classes, source };
|
|
13909
|
+
}),
|
|
12832
13910
|
manifestState: connection
|
|
12833
13911
|
? { ...connection.manifestState }
|
|
12834
13912
|
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
@@ -12867,7 +13945,7 @@ export class AgentFramework {
|
|
|
12867
13945
|
) => {
|
|
12868
13946
|
const barrier = this.discordAwarenessBarrier;
|
|
12869
13947
|
if (barrier) await barrier.promise;
|
|
12870
|
-
this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
|
|
13948
|
+
await this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
|
|
12871
13949
|
});
|
|
12872
13950
|
|
|
12873
13951
|
// Handle server-initiated inference requests (Step 6)
|
|
@@ -12875,6 +13953,11 @@ export class AgentFramework {
|
|
|
12875
13953
|
params: McplInferenceRequestParams,
|
|
12876
13954
|
responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
12877
13955
|
) => {
|
|
13956
|
+
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
|
|
13957
|
+
if (this.pushCoalescer?.isRendering(connection.id)) {
|
|
13958
|
+
responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
|
|
13959
|
+
return;
|
|
13960
|
+
}
|
|
12878
13961
|
if (this.inferenceRouter && responder) {
|
|
12879
13962
|
const barrier = this.discordAwarenessBarrier;
|
|
12880
13963
|
if (barrier) await barrier.promise;
|
|
@@ -12925,6 +14008,23 @@ export class AgentFramework {
|
|
|
12925
14008
|
});
|
|
12926
14009
|
});
|
|
12927
14010
|
|
|
14011
|
+
// RFC-007 §6: the server's filter over tools/lifecycle. Admission already
|
|
14012
|
+
// required toolLifecycle.observe (-32002 otherwise). Validation failures
|
|
14013
|
+
// leave the previous filter in force (§6.6).
|
|
14014
|
+
connection.on('tools-observe', (
|
|
14015
|
+
params: unknown,
|
|
14016
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
|
|
14017
|
+
) => {
|
|
14018
|
+
const parsed = parseToolObserveParams(params);
|
|
14019
|
+
if (!parsed.ok) {
|
|
14020
|
+
if (responder?.respondError) responder.respondError(-32602, parsed.message, parsed.data);
|
|
14021
|
+
else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
|
|
14022
|
+
return;
|
|
14023
|
+
}
|
|
14024
|
+
connection.toolObserveFilter = parsed.rules;
|
|
14025
|
+
responder?.respond({});
|
|
14026
|
+
});
|
|
14027
|
+
|
|
12928
14028
|
connection.on('model-info', (
|
|
12929
14029
|
_params: unknown,
|
|
12930
14030
|
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
|
|
@@ -12948,7 +14048,7 @@ export class AgentFramework {
|
|
|
12948
14048
|
) => {
|
|
12949
14049
|
const barrier = this.discordAwarenessBarrier;
|
|
12950
14050
|
if (barrier) await barrier.promise;
|
|
12951
|
-
this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
|
|
14051
|
+
await this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
|
|
12952
14052
|
});
|
|
12953
14053
|
|
|
12954
14054
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
@@ -13111,6 +14211,10 @@ export class AgentFramework {
|
|
|
13111
14211
|
// disconnectMcplServer, which deletes the trees explicitly.
|
|
13112
14212
|
connection.on('close', (code?: number | null, signal?: string | null) => {
|
|
13113
14213
|
this.featureSetManager?.removeServer(connection.id);
|
|
14214
|
+
// RFC-008: a closed provider's class hints are stale — on reconnect it
|
|
14215
|
+
// may class its tools differently. Until the next tools/list its tools
|
|
14216
|
+
// are unclassed, the restrictive answer (RFC-007 §4.3).
|
|
14217
|
+
this.dropMcplToolClasses(connection.id);
|
|
13114
14218
|
this.emitTrace({
|
|
13115
14219
|
type: 'mcpl:server-closed',
|
|
13116
14220
|
serverId: connection.id,
|
|
@@ -13131,6 +14235,8 @@ export class AgentFramework {
|
|
|
13131
14235
|
|
|
13132
14236
|
const tools: import('./types/index.js').ToolDefinition[] = [];
|
|
13133
14237
|
const toolFeatureSets = new Map<string, string>();
|
|
14238
|
+
const toolClasses = new Map<string, ToolClass[]>();
|
|
14239
|
+
const toolServers = new Map<string, string>();
|
|
13134
14240
|
|
|
13135
14241
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
13136
14242
|
const config = this.mcplServerConfigs.get(server.id);
|
|
@@ -13140,10 +14246,15 @@ export class AgentFramework {
|
|
|
13140
14246
|
for (const tool of result.tools) {
|
|
13141
14247
|
if (!isToolAllowed(tool.name, config)) continue;
|
|
13142
14248
|
const namespacedName = `${prefix}--${tool.name}`;
|
|
14249
|
+
toolServers.set(namespacedName, server.id);
|
|
13143
14250
|
const attributedTool = tool as typeof tool & {
|
|
13144
14251
|
featureSet?: unknown;
|
|
13145
|
-
_meta?: { featureSet?: unknown };
|
|
14252
|
+
_meta?: { featureSet?: unknown; [key: string]: unknown };
|
|
13146
14253
|
};
|
|
14254
|
+
// RFC-008: the server's class hint for this tool. Never shown to
|
|
14255
|
+
// the model (the definition below is unchanged by it).
|
|
14256
|
+
const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
|
|
14257
|
+
if (declaredClasses) toolClasses.set(namespacedName, declaredClasses);
|
|
13147
14258
|
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
13148
14259
|
? attributedTool.featureSet
|
|
13149
14260
|
: typeof attributedTool._meta?.featureSet === 'string'
|
|
@@ -13167,6 +14278,8 @@ export class AgentFramework {
|
|
|
13167
14278
|
|
|
13168
14279
|
this.mcplTools = tools;
|
|
13169
14280
|
this.mcplToolFeatureSets = toolFeatureSets;
|
|
14281
|
+
this.mcplToolClasses = toolClasses;
|
|
14282
|
+
this.mcplToolServers = toolServers;
|
|
13170
14283
|
}
|
|
13171
14284
|
|
|
13172
14285
|
/**
|
|
@@ -13232,6 +14345,8 @@ export class AgentFramework {
|
|
|
13232
14345
|
const server = this.mcplServerRegistry!.getServer(serverId);
|
|
13233
14346
|
|
|
13234
14347
|
if (!server) {
|
|
14348
|
+
// RFC-007: refused before executing — no lifecycle events.
|
|
14349
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
13235
14350
|
this.pushEvent({
|
|
13236
14351
|
type: 'tool-result',
|
|
13237
14352
|
callId: call.id,
|
|
@@ -13244,6 +14359,7 @@ export class AgentFramework {
|
|
|
13244
14359
|
|
|
13245
14360
|
const config = this.mcplServerConfigs.get(serverId);
|
|
13246
14361
|
if (!isToolAllowed(toolName, config)) {
|
|
14362
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
13247
14363
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
|
|
13248
14364
|
this.pushEvent({
|
|
13249
14365
|
type: 'tool-result',
|
|
@@ -13420,6 +14536,8 @@ export class AgentFramework {
|
|
|
13420
14536
|
.catch((error) => {
|
|
13421
14537
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
13422
14538
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
|
|
14539
|
+
// RFC-007: transport error, closed connection or timeout — no result.
|
|
14540
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
13423
14541
|
|
|
13424
14542
|
this.pushEvent({
|
|
13425
14543
|
type: 'tool-result',
|
|
@@ -13446,6 +14564,8 @@ export class AgentFramework {
|
|
|
13446
14564
|
const home = this.conversationAgentHomes.get(agentName);
|
|
13447
14565
|
if (home) {
|
|
13448
14566
|
const reject = (error: string): void => {
|
|
14567
|
+
// RFC-007: the guard refuses before the tool runs — no lifecycle events.
|
|
14568
|
+
this.toolLifecycleEmitter?.refuse(agentName, call.id);
|
|
13449
14569
|
this.emitTrace({
|
|
13450
14570
|
type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
|
|
13451
14571
|
error: `conversation agent ${agentName}: ${error}`,
|
|
@@ -13529,6 +14649,7 @@ export class AgentFramework {
|
|
|
13529
14649
|
.catch((error) => {
|
|
13530
14650
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
13531
14651
|
this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
|
|
14652
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
13532
14653
|
this.pushEvent({
|
|
13533
14654
|
type: 'tool-result',
|
|
13534
14655
|
callId: call.id,
|
|
@@ -13767,7 +14888,8 @@ export class AgentFramework {
|
|
|
13767
14888
|
if (announce && this.channelRegistry) {
|
|
13768
14889
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
13769
14890
|
const agent = this.agents.get(agentName);
|
|
13770
|
-
|
|
14891
|
+
const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
|
|
14892
|
+
if (silentTurn || agent?.proseRouting === 'disabled') {
|
|
13771
14893
|
console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
|
|
13772
14894
|
} else if (agent?.proseRouting === 'explicit') {
|
|
13773
14895
|
// Explicit mode: announce to the turn's sticky prose target if the
|
|
@@ -14611,6 +15733,7 @@ export class AgentFramework {
|
|
|
14611
15733
|
} catch (error) {
|
|
14612
15734
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
14613
15735
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
15736
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
14614
15737
|
result = { success: false, error: err.message, isError: true };
|
|
14615
15738
|
}
|
|
14616
15739
|
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
@@ -14635,6 +15758,7 @@ export class AgentFramework {
|
|
|
14635
15758
|
.catch((error) => {
|
|
14636
15759
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
14637
15760
|
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
15761
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
14638
15762
|
this.pushEvent({
|
|
14639
15763
|
type: 'tool-result',
|
|
14640
15764
|
callId: call.id,
|