@animalabs/agent-framework 0.21.1 → 0.22.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 +43 -0
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +102 -42
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +250 -39
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1602 -371
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +5 -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/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +4 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +45 -8
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +81 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +257 -16
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1059 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +278 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +805 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +224 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +901 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +125 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +310 -35
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +4 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +14 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +73 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +977 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +19 -6
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +449 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +102 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2330 -18
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3079 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +120 -44
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +1661 -363
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +11 -0
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/server-connection.ts +41 -10
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +252 -16
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1058 -446
- package/src/modules/workspace/observe.ts +839 -0
- package/src/modules/workspace/reconcile.ts +1136 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/record-journal.ts +246 -0
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +335 -33
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +4 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +15 -0
- package/src/types/trace.ts +80 -0
package/dist/src/framework.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue } from "./tool-presentation.js";
|
|
1
2
|
import { dirname, join } from 'node:path';
|
|
2
3
|
import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js';
|
|
3
4
|
import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js';
|
|
@@ -8,26 +9,29 @@ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverB
|
|
|
8
9
|
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
|
|
9
10
|
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
10
11
|
import { ProcessQueueImpl } from './queue.js';
|
|
12
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
|
|
11
13
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
12
14
|
import { Agent } from './agent.js';
|
|
13
|
-
import { ModuleRegistry, isStateExistsError } from './module-registry.js';
|
|
15
|
+
import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
|
|
14
16
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
15
17
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
16
18
|
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
17
19
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
18
20
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
19
21
|
import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
|
|
22
|
+
import { checkToolPattern, describeUnmatchedPattern } from './mcpl/tool-pattern-check.js';
|
|
20
23
|
import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass } from './mcpl/tool-classes.js';
|
|
21
24
|
import { PushHandler, convertBlock as convertPushBlock } from './mcpl/push-handler.js';
|
|
22
25
|
import { PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent, coalescingSubjectKey, } from './mcpl/push-coalescer.js';
|
|
26
|
+
import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './mcpl/visible-content.js';
|
|
23
27
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
24
28
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
25
29
|
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
26
30
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
27
31
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
28
|
-
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
32
|
+
import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
|
|
29
33
|
import { safeSlice } from './safe-slice.js';
|
|
30
|
-
import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
|
|
34
|
+
import { nonErrorResultText, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
|
|
31
35
|
import { TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, parseImagePlaceholders, sha256Hex, splitPreservingImageSlots, } from './tool-image-ledger.js';
|
|
32
36
|
import { randomUUID, createHash } from 'node:crypto';
|
|
33
37
|
import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
|
|
@@ -306,6 +310,43 @@ const bySeq = (a, b) => a.seq - b.seq;
|
|
|
306
310
|
function withDeferredWriteId(metadata, id) {
|
|
307
311
|
return { ...(metadata ?? {}), deferredWriteId: id };
|
|
308
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* A prose block's envelopes, as explicit mode routes them: every line whose
|
|
315
|
+
* text, after its leading whitespace, is `>>` and then a non-space opens a
|
|
316
|
+
* new envelope (first live use: Tilde 2026-07-24, one block carrying a
|
|
317
|
+
* letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
|
|
318
|
+
* parser delivered the aside literally into the first destination — exactly
|
|
319
|
+
* the leak class this mode exists to kill). Text before the first `>>` is
|
|
320
|
+
* its own unprefixed envelope. A quoted `>> arrow` (space after) stays body
|
|
321
|
+
* text. Blank envelopes are dropped, and an envelope's whitespace before its
|
|
322
|
+
* `>>` is trimmed.
|
|
323
|
+
*/
|
|
324
|
+
function splitExplicitEnvelopes(rawText) {
|
|
325
|
+
const envelopes = [];
|
|
326
|
+
let current = [];
|
|
327
|
+
for (const line of rawText.split('\n')) {
|
|
328
|
+
if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
|
|
329
|
+
envelopes.push(current.join('\n'));
|
|
330
|
+
current = [];
|
|
331
|
+
}
|
|
332
|
+
current.push(line);
|
|
333
|
+
}
|
|
334
|
+
if (current.length > 0)
|
|
335
|
+
envelopes.push(current.join('\n'));
|
|
336
|
+
return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, ''));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Whether a stream resumes its logical turn rather than starting one: a budget
|
|
340
|
+
* restart, or a tool-result-guard retry. Such a stream keeps the turn's
|
|
341
|
+
* routing state (the sticky destination, the turn's destination) and queues
|
|
342
|
+
* its deliveries behind the turn's earlier ones.
|
|
343
|
+
*/
|
|
344
|
+
function resumesLogicalTurn(reason) {
|
|
345
|
+
return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
|
|
346
|
+
}
|
|
347
|
+
function freshTurnRouting() {
|
|
348
|
+
return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } };
|
|
349
|
+
}
|
|
309
350
|
function isTurnContinuation(reason) {
|
|
310
351
|
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
311
352
|
|| reason === 'tool_result_guard_retry';
|
|
@@ -463,6 +504,10 @@ class DefaultErrorPolicy {
|
|
|
463
504
|
}
|
|
464
505
|
/** Default sync interval in milliseconds */
|
|
465
506
|
const DEFAULT_SYNC_INTERVAL_MS = 1000;
|
|
507
|
+
/** Total time one withheld batch may spend awaiting workspace writes.
|
|
508
|
+
* At the deadline its unfinished write is named as such, and later
|
|
509
|
+
* originals stay in the audit. This bound applies to withheld spills. */
|
|
510
|
+
const WITHHELD_SPILL_DEADLINE_MS = 30_000;
|
|
466
511
|
/** Poll pending context-strategy maintenance independently of user activity. */
|
|
467
512
|
const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
|
|
468
513
|
/** Bound one pass so a large backlog yields to inference and other agents. */
|
|
@@ -513,9 +558,90 @@ function extractGateFields(event) {
|
|
|
513
558
|
function truncateReason(reason, max = 160) {
|
|
514
559
|
return reason.length <= max ? reason : reason.slice(0, max) + '…';
|
|
515
560
|
}
|
|
561
|
+
/** The most of a thrown value's or a tool error's text that reaches a log line or a server's error answer. */
|
|
562
|
+
const THROWN_TEXT_LIMIT = 500;
|
|
563
|
+
/**
|
|
564
|
+
* What a thrown value says, safely and briefly: an Error's message, or any
|
|
565
|
+
* other value, as a string. Every inspection and coercion of the value is
|
|
566
|
+
* inside the try, so reading it can't throw: a revoked Proxy (whose
|
|
567
|
+
* instanceof throws), a hostile getter, an Error whose message isn't a
|
|
568
|
+
* string, or a value with no string form reads as text or as unreadable.
|
|
569
|
+
* The text is bounded, since a provider or handler error can carry a whole
|
|
570
|
+
* request body.
|
|
571
|
+
*/
|
|
572
|
+
function thrownText(error) {
|
|
573
|
+
let text;
|
|
574
|
+
try {
|
|
575
|
+
text = String(error instanceof Error ? error.message : error);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
text = '(a value that could not be read)';
|
|
579
|
+
}
|
|
580
|
+
return text.length <= THROWN_TEXT_LIMIT ? text : `${text.slice(0, THROWN_TEXT_LIMIT)}… (${text.length} characters)`;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* A failed handler's reason, never throwing: an Error's message, else "the
|
|
584
|
+
* handler failed" and what it threw (nothing, for `undefined`). Telling an
|
|
585
|
+
* Error apart is itself guarded, since instanceof throws on a revoked Proxy.
|
|
586
|
+
*/
|
|
587
|
+
function handlerFailureText(error) {
|
|
588
|
+
let isError = false;
|
|
589
|
+
try {
|
|
590
|
+
isError = error instanceof Error;
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// Not an Error that can be read; thrownText says so.
|
|
594
|
+
}
|
|
595
|
+
if (isError)
|
|
596
|
+
return thrownText(error);
|
|
597
|
+
return error === undefined ? 'the handler failed' : `the handler failed: ${thrownText(error)}`;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Wrap a server request's responder so the request is answered at most once,
|
|
601
|
+
* and a later failure can tell whether the handler already answered.
|
|
602
|
+
* - An answer counts once the responder returns. One that throws, such as a
|
|
603
|
+
* result the transport can't serialize, leaves the request unanswered, so
|
|
604
|
+
* the listener's failure answer still goes out. This assumes a responder
|
|
605
|
+
* that throws has sent nothing, which holds for the connection's own: they
|
|
606
|
+
* serialize before they write, and their transports' writes don't throw
|
|
607
|
+
* after writing.
|
|
608
|
+
* - A second answer is refused and logged: a handler that answered and then
|
|
609
|
+
* threw isn't answered again, and a handler that answers after the
|
|
610
|
+
* listener answered its failure doesn't send a second response.
|
|
611
|
+
* - Only the methods the responder has are wrapped, so a handler that checks
|
|
612
|
+
* for `respondError` still finds a responder without one.
|
|
613
|
+
*/
|
|
614
|
+
function answerOnce(responder, method, serverId) {
|
|
615
|
+
if (!responder)
|
|
616
|
+
return { responder: undefined, answered: () => false };
|
|
617
|
+
let done = false;
|
|
618
|
+
const once = (kind, send) => (...args) => {
|
|
619
|
+
if (done) {
|
|
620
|
+
console.error(`[mcpl] ${method} from ${serverId}: a second answer (${kind}) was refused; the request was already answered`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
send(...args);
|
|
624
|
+
done = true;
|
|
625
|
+
};
|
|
626
|
+
const wrapped = { ...responder };
|
|
627
|
+
if (responder.respond)
|
|
628
|
+
wrapped.respond = once('respond', (result) => responder.respond(result));
|
|
629
|
+
if (responder.respondError) {
|
|
630
|
+
wrapped.respondError = once('respondError', (code, message, data) => responder.respondError(code, message, data));
|
|
631
|
+
}
|
|
632
|
+
return { responder: wrapped, answered: () => done };
|
|
633
|
+
}
|
|
516
634
|
export class AgentFramework {
|
|
635
|
+
toolPresentations = new Map();
|
|
636
|
+
presentationPreviews = new WeakMap();
|
|
637
|
+
getRequestToolPresentation(request) {
|
|
638
|
+
return this.presentationPreviews.get(request) ?? null;
|
|
639
|
+
}
|
|
517
640
|
store;
|
|
518
641
|
ownsStore;
|
|
642
|
+
stopPromise = null;
|
|
643
|
+
stopCompleted = false;
|
|
644
|
+
ownedStoreClosed = false;
|
|
519
645
|
membrane;
|
|
520
646
|
queue;
|
|
521
647
|
agents = new Map();
|
|
@@ -563,6 +689,8 @@ export class AgentFramework {
|
|
|
563
689
|
providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
|
|
564
690
|
providerHoldHook;
|
|
565
691
|
providerAdmissionClosed = false;
|
|
692
|
+
/** Invalidates framework turns still assembling when stop begins. */
|
|
693
|
+
lifecycleGeneration = 0;
|
|
566
694
|
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
567
695
|
staleWarnAt = new Map();
|
|
568
696
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
@@ -596,26 +724,6 @@ export class AgentFramework {
|
|
|
596
724
|
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
597
725
|
* turn's start; budget restarts keep it. */
|
|
598
726
|
turnEngagedChannels = new Map();
|
|
599
|
-
/** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
|
|
600
|
-
* order (deduped at render). Feeds the `[delivered]` receipt appended at
|
|
601
|
-
* logical turn end: explicit sends receipt themselves via their
|
|
602
|
-
* tool_result, but auto-routed prose previously vanished into the router
|
|
603
|
-
* with no in-window record — the agent could never see where its own
|
|
604
|
-
* words landed (2026-07-31 misroute series; antra: minimal receipts).
|
|
605
|
-
* Cleared at every fresh turn's start; budget restarts keep it (same
|
|
606
|
-
* logical turn, receipt covers the whole turn). */
|
|
607
|
-
turnProseDeliveries = new Map();
|
|
608
|
-
/** Count of plain-prose segments SUPPRESSED this turn by sticky
|
|
609
|
-
* explicit-send silencing. The suppression rule itself is untouched
|
|
610
|
-
* (antra, 2026-07-31: visibility is enough) — but it must never be a
|
|
611
|
-
* silent black hole: Mythos multiplexed two threads in one round (prose
|
|
612
|
-
* for the lane + explicit send for another channel, textbook per the
|
|
613
|
-
* routing doc), the silencing ate the prose reply, and his own record
|
|
614
|
-
* ("scene delivered to #mythos via plain speech — routing confirmed")
|
|
615
|
-
* believed it delivered. The receipt now reports suppression, so the
|
|
616
|
-
* author sees the segment's fate one turn later. Cleared each fresh
|
|
617
|
-
* turn; budget restarts keep it. */
|
|
618
|
-
turnProseSuppressed = new Map();
|
|
619
727
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
620
728
|
* not a reaction or a system marker) into the live stream. Tells
|
|
621
729
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -629,14 +737,43 @@ export class AgentFramework {
|
|
|
629
737
|
// ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
|
|
630
738
|
/** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
|
|
631
739
|
proseClipboards = new Map();
|
|
632
|
-
/**
|
|
633
|
-
*
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
*
|
|
639
|
-
|
|
740
|
+
/**
|
|
741
|
+
* Each agent's routing state for its current turn (TurnRouting): the
|
|
742
|
+
* sticky destination, the turn's destination for a resumed stream,
|
|
743
|
+
* hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object
|
|
744
|
+
* instead of clearing the old one, and a stream that resumes the turn
|
|
745
|
+
* (resumesLogicalTurn) keeps it. A queued delivery holds the object of the
|
|
746
|
+
* turn that queued it, so prose a turn leaves queued when it ends (a
|
|
747
|
+
* skip_reply ends it before its posts have run) still follows that turn's
|
|
748
|
+
* destination, and its late writes can't reach the next turn.
|
|
749
|
+
*/
|
|
750
|
+
turnRouting = new Map();
|
|
751
|
+
/**
|
|
752
|
+
* The latest link of each agent's chain of queued prose deliveries. Every
|
|
753
|
+
* stream queues its own behind it, a fresh turn's included, so the agent's
|
|
754
|
+
* posts go out in the order they were written, across streams and turns.
|
|
755
|
+
*/
|
|
756
|
+
proseSpeechChains = new Map();
|
|
757
|
+
/**
|
|
758
|
+
* Each agent's open logical turn (LogicalTurn). It stays open from the
|
|
759
|
+
* fresh start that opens it until the turn settles (settleAgent: it
|
|
760
|
+
* completes, a tool ends it, or it fails or is abandoned), and every fresh
|
|
761
|
+
* start that routes prose while it is open continues it: a framework
|
|
762
|
+
* retry, the start that consumes tool results the non-streaming fallback
|
|
763
|
+
* left (whichever wake the scheduler starts it with), the later compile a
|
|
764
|
+
* provider-capacity hold defers a turn to. A silent start neither
|
|
765
|
+
* continues nor opens one. A stream that ends without settling needs to do
|
|
766
|
+
* nothing to carry its turn. Settling only detaches the turn from future
|
|
767
|
+
* starts: the settling stream keeps its own object, which its remaining
|
|
768
|
+
* deliveries, its receipt and its ` !` check go on using (a completion
|
|
769
|
+
* settles before it awaits its chain and writes its receipt). So every
|
|
770
|
+
* logical end must close inheritance once, before the next fresh start:
|
|
771
|
+
* a turn left open would hand its object, late writes and all, to an
|
|
772
|
+
* unrelated turn. A tool-ended turn settles before the next turn can
|
|
773
|
+
* start. Only the stream that holds the open turn closes it; disposal
|
|
774
|
+
* drops it, and stop() clears every agent's.
|
|
775
|
+
*/
|
|
776
|
+
openLogical = new Map();
|
|
640
777
|
/** Consecutive bounce-wakes per agent; capped so an agent that keeps
|
|
641
778
|
* emitting unprefixed prose can't wake-loop (notices still append). */
|
|
642
779
|
proseBounceStreaks = new Map();
|
|
@@ -842,10 +979,16 @@ export class AgentFramework {
|
|
|
842
979
|
codeExecutionConfig = null;
|
|
843
980
|
codeExecutionRunners = new Map();
|
|
844
981
|
scriptToolWaiters = new Map();
|
|
845
|
-
/**
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
|
|
982
|
+
/** Per agent, the foreground script now running. An endTurn-carrying inner
|
|
983
|
+
* result marks the script that made the call, and is applied to that
|
|
984
|
+
* script's code_execution result instead of cancelling the stream
|
|
985
|
+
* mid-script (which would wedge the turn). A stopped script's late result
|
|
986
|
+
* marks only that script, never the agent's next one. */
|
|
987
|
+
scriptDeferredEndTurn = new Map();
|
|
988
|
+
/** RFC-007: per agent, the origin of the foreground script its runner is
|
|
989
|
+
* executing, handed to that script's inner calls (scripts run one at a
|
|
990
|
+
* time per runner). Background runners capture theirs in the closure. */
|
|
991
|
+
foregroundScriptOrigins = new Map();
|
|
849
992
|
/** Background (daemon) scripts: model-authored watchers that outlive their
|
|
850
993
|
* spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
|
|
851
994
|
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
@@ -876,6 +1019,16 @@ export class AgentFramework {
|
|
|
876
1019
|
/** Namespaced tool name → the server whose tools/list produced it. Prefixes
|
|
877
1020
|
* can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
|
|
878
1021
|
mcplToolServers = new Map();
|
|
1022
|
+
/** Servers whose tools/list answered in the latest refresh. */
|
|
1023
|
+
mcplListedServers = new Set();
|
|
1024
|
+
/** Zero-match tool-pattern diagnostics (checkToolPatterns): set at the end
|
|
1025
|
+
* of create(), when the tool universe is first complete. */
|
|
1026
|
+
toolPatternChecksArmed = false;
|
|
1027
|
+
/** Patterns already reported as matching nothing — each is reported once. */
|
|
1028
|
+
toolPatternsReported = new Set();
|
|
1029
|
+
/** Every (prefix → serverId) ever configured. A removed or restarting
|
|
1030
|
+
* server's patterns stay undecided rather than reported mid-restart. */
|
|
1031
|
+
toolPatternKnownPrefixes = new Map();
|
|
879
1032
|
/** Maps serverId → McplServerConfig for prefix lookup. */
|
|
880
1033
|
mcplServerConfigs = new Map();
|
|
881
1034
|
/** Host capabilities advertised during the MCP handshake — stored so servers
|
|
@@ -1066,6 +1219,8 @@ export class AgentFramework {
|
|
|
1066
1219
|
// Create agents
|
|
1067
1220
|
for (const agentConfig of config.agents) {
|
|
1068
1221
|
await framework.createAgent(agentConfig);
|
|
1222
|
+
if (agentConfig.toolPresentation)
|
|
1223
|
+
framework.toolPresentations.set(agentConfig.name, new ToolPresentation(agentConfig.toolPresentation));
|
|
1069
1224
|
}
|
|
1070
1225
|
// The subconscious resident (issue #77) registers after the residents so
|
|
1071
1226
|
// it can never become primary; it is excluded from every broadcast
|
|
@@ -1080,6 +1235,17 @@ export class AgentFramework {
|
|
|
1080
1235
|
for (const module of config.modules) {
|
|
1081
1236
|
await framework.addModule(module);
|
|
1082
1237
|
}
|
|
1238
|
+
for (const [agentName, presentation] of framework.toolPresentations) {
|
|
1239
|
+
const workspace = framework.getWorkspaceModule();
|
|
1240
|
+
if (!workspace)
|
|
1241
|
+
throw new Error('Tool presentation requires workspace');
|
|
1242
|
+
const agent = framework.agents.get(agentName);
|
|
1243
|
+
for (const name of ['workspace--read', 'set_tool_visibility', 'set_tool_description']) {
|
|
1244
|
+
if (!agent.canUseTool(name))
|
|
1245
|
+
throw new Error(`Tool presentation recovery requires ${name}`);
|
|
1246
|
+
}
|
|
1247
|
+
workspace.registerGeneratedTextFile(presentation.config.cataloguePath, () => renderCatalogue(framework.inspectToolPresentation(agentName)), agentName);
|
|
1248
|
+
}
|
|
1083
1249
|
// Initialize per-channel conversation routing (if configured)
|
|
1084
1250
|
if (config.conversations) {
|
|
1085
1251
|
if (!framework.agents.has(config.conversations.templateAgent)) {
|
|
@@ -1087,6 +1253,9 @@ export class AgentFramework {
|
|
|
1087
1253
|
`is not a configured agent`);
|
|
1088
1254
|
}
|
|
1089
1255
|
framework.conversationRouter = new ConversationRouter(config.conversations);
|
|
1256
|
+
// Deprecated feature (agent-framework#235): still wired exactly as
|
|
1257
|
+
// before, but named once per framework instance on stderr.
|
|
1258
|
+
console.warn(`[deprecated] ${CONVERSATION_ROUTING_DEPRECATION_NOTICE}`);
|
|
1090
1259
|
// Generation counters persist across restarts — reusing generation 1's
|
|
1091
1260
|
// agent name after a restart would reopen (and re-seed) the previous
|
|
1092
1261
|
// engagement's Chronicle namespace.
|
|
@@ -1122,15 +1291,41 @@ export class AgentFramework {
|
|
|
1122
1291
|
framework.pendingRequests.push({
|
|
1123
1292
|
agentName, reason, source, timestamp: Date.now(),
|
|
1124
1293
|
// gate-requested wakes carry where/who (EventGate wakeProvenance)
|
|
1125
|
-
// as TELEMETRY fields — the host's stamp reads them
|
|
1126
|
-
// speech locus (channelId / addressed) is deliberately NOT set
|
|
1127
|
-
// here, so a batched wake routes exactly as it did before.
|
|
1294
|
+
// as TELEMETRY fields — the host's stamp reads them.
|
|
1128
1295
|
...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
|
|
1129
1296
|
...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
|
|
1130
1297
|
...(provenance?.at ? { wakeAt: provenance.at } : {}),
|
|
1298
|
+
// A batch that contains an ADDRESSED message (mention / reply /
|
|
1299
|
+
// DM) routes like the direct path does: the reply goes to that
|
|
1300
|
+
// message's channel. Without it the turn froze on the
|
|
1301
|
+
// process-global most-recent-inbound channel, which an ambient
|
|
1302
|
+
// message elsewhere could retarget between the DM's arrival and
|
|
1303
|
+
// the debounce firing (2026-09-30). Ambient-only batches set no
|
|
1304
|
+
// locus and keep the legacy fallback.
|
|
1305
|
+
//
|
|
1306
|
+
// The wake is broadcast to every agent, conversation forks
|
|
1307
|
+
// included, and a fork speaks only in its home channel: the
|
|
1308
|
+
// route applies to a fork only when it IS that home, and never to
|
|
1309
|
+
// another agent when a fork owns that channel. Otherwise a fork
|
|
1310
|
+
// bound elsewhere would show typing where it isn't replying.
|
|
1311
|
+
...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
|
|
1312
|
+
? { channelId: provenance.routeChannelId, addressed: true }
|
|
1313
|
+
: {}),
|
|
1131
1314
|
});
|
|
1132
1315
|
},
|
|
1133
1316
|
getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
|
|
1317
|
+
// Push events reach the gate with the adapter's raw channel id; map
|
|
1318
|
+
// them to the registered composite id the same way ingestion does
|
|
1319
|
+
// (handleMcplPushEvent registers that channel on arrival, before any
|
|
1320
|
+
// debounce can fire).
|
|
1321
|
+
resolveRouteChannel: (info) => {
|
|
1322
|
+
if (info.eventType === 'mcpl:channel-incoming')
|
|
1323
|
+
return info.channelId || undefined;
|
|
1324
|
+
if (info.eventType === 'mcpl:push-event') {
|
|
1325
|
+
return framework.derivePushEventChannel(info.metadata)?.channelId;
|
|
1326
|
+
}
|
|
1327
|
+
return undefined;
|
|
1328
|
+
},
|
|
1134
1329
|
});
|
|
1135
1330
|
}
|
|
1136
1331
|
// Liveness watchdog: fail hard if the main thread wedges (opt-in).
|
|
@@ -1284,6 +1479,11 @@ export class AgentFramework {
|
|
|
1284
1479
|
catch {
|
|
1285
1480
|
// SIGUSR2 not available on this platform — non-fatal.
|
|
1286
1481
|
}
|
|
1482
|
+
// Modules, agents, MCPL servers and the tune-out tool are all in place:
|
|
1483
|
+
// the first point at which "matches no tool" can be judged. Later tool
|
|
1484
|
+
// refreshes re-check patterns still undecided.
|
|
1485
|
+
framework.toolPatternChecksArmed = true;
|
|
1486
|
+
framework.checkToolPatterns();
|
|
1287
1487
|
return framework;
|
|
1288
1488
|
}
|
|
1289
1489
|
/**
|
|
@@ -1293,8 +1493,13 @@ export class AgentFramework {
|
|
|
1293
1493
|
if (this.running) {
|
|
1294
1494
|
return;
|
|
1295
1495
|
}
|
|
1496
|
+
this.resetCompletedStop();
|
|
1296
1497
|
this.running = true;
|
|
1297
1498
|
this.providerAdmissionClosed = false;
|
|
1499
|
+
// A new lifecycle: a turn compiled before an earlier stop() never drives,
|
|
1500
|
+
// and withheld spills, closed for the generation a stop ended, are open
|
|
1501
|
+
// for this one.
|
|
1502
|
+
this.lifecycleGeneration++;
|
|
1298
1503
|
this.loopPromise = this.runLoop();
|
|
1299
1504
|
// Start periodic sync timer (if enabled)
|
|
1300
1505
|
if (this.syncIntervalMs > 0) {
|
|
@@ -1318,9 +1523,41 @@ export class AgentFramework {
|
|
|
1318
1523
|
}
|
|
1319
1524
|
}
|
|
1320
1525
|
/**
|
|
1321
|
-
* Stop the event loop.
|
|
1526
|
+
* Stop the event loop. Failed module/MCPL cleanup can be retried with its
|
|
1527
|
+
* original context; storage stays open until that cleanup succeeds.
|
|
1528
|
+
* @param teardownTimeoutMs Optional module/MCPL wait budget. Omit to wait
|
|
1529
|
+
* until cleanup settles, as required by existing module/child contracts. This
|
|
1530
|
+
* does not bound earlier stream or event-loop quiescence, or cancel cleanup.
|
|
1322
1531
|
*/
|
|
1323
|
-
|
|
1532
|
+
stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
1533
|
+
try {
|
|
1534
|
+
validateShutdownTimeout(teardownTimeoutMs);
|
|
1535
|
+
}
|
|
1536
|
+
catch (error) {
|
|
1537
|
+
return Promise.reject(error);
|
|
1538
|
+
}
|
|
1539
|
+
if (this.stopPromise)
|
|
1540
|
+
return this.stopPromise;
|
|
1541
|
+
const attempt = this.stopOwned(teardownTimeoutMs);
|
|
1542
|
+
this.stopPromise = attempt;
|
|
1543
|
+
void attempt.then(() => { this.stopCompleted = true; }, () => { });
|
|
1544
|
+
// Failed cleanup can be retried while its original store is open. A
|
|
1545
|
+
// terminal storage failure after successful close must remain a failure.
|
|
1546
|
+
void attempt.catch(() => {
|
|
1547
|
+
if (!this.ownedStoreClosed && this.stopPromise === attempt)
|
|
1548
|
+
this.stopPromise = null;
|
|
1549
|
+
});
|
|
1550
|
+
return attempt;
|
|
1551
|
+
}
|
|
1552
|
+
resetCompletedStop() {
|
|
1553
|
+
if (this.ownedStoreClosed)
|
|
1554
|
+
throw new Error('Cannot start a new lifecycle: owned store is closed');
|
|
1555
|
+
if (this.stopCompleted) {
|
|
1556
|
+
this.stopPromise = null;
|
|
1557
|
+
this.stopCompleted = false;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async stopOwned(teardownTimeoutMs) {
|
|
1324
1561
|
this.pushCoalescer?.suspend();
|
|
1325
1562
|
this.flushCoalescingSnapshot();
|
|
1326
1563
|
this.running = false;
|
|
@@ -1328,6 +1565,10 @@ export class AgentFramework {
|
|
|
1328
1565
|
// store is still open, rather than leaving them to a reboot replay.
|
|
1329
1566
|
this.ackDeferredWrites();
|
|
1330
1567
|
this.providerAdmissionClosed = true;
|
|
1568
|
+
this.lifecycleGeneration++;
|
|
1569
|
+
// The generation this stop ends; withheld spills close for it alone, so a
|
|
1570
|
+
// start() made before this stop finishes reopens them for its own.
|
|
1571
|
+
const stoppingGeneration = this.lifecycleGeneration;
|
|
1331
1572
|
this.queue.close();
|
|
1332
1573
|
this.tuneOutCoordinator?.stop();
|
|
1333
1574
|
// Kill running code_execution scripts before cancelling streams: a
|
|
@@ -1353,6 +1594,10 @@ export class AgentFramework {
|
|
|
1353
1594
|
this.toolImageLedgers.clear();
|
|
1354
1595
|
// A stopped host must never hang behind its own cooldown.
|
|
1355
1596
|
this.cancelProviderAdmission();
|
|
1597
|
+
// An open logical turn doesn't survive a stop: a start after a restart
|
|
1598
|
+
// on this instance opens a new one rather than carrying its ` !` and
|
|
1599
|
+
// receipt tallies across (openLogical).
|
|
1600
|
+
this.openLogical.clear();
|
|
1356
1601
|
// Cancel all active streams
|
|
1357
1602
|
for (const agent of this.agents.values()) {
|
|
1358
1603
|
if (agent.state.status === 'streaming' ||
|
|
@@ -1384,20 +1629,46 @@ export class AgentFramework {
|
|
|
1384
1629
|
// Dispose EventGate (clear debounce timers)
|
|
1385
1630
|
this.eventGate?.dispose();
|
|
1386
1631
|
this.livenessWatchdog?.stop();
|
|
1632
|
+
// A stopping stream's batch settles as withheld, and its stub names where
|
|
1633
|
+
// the original went: let those writes finish while the workspace is up.
|
|
1634
|
+
// Tracked here rather than per agent, since an ephemeral run or a closed
|
|
1635
|
+
// fork leaves the agent map while its annotation can still be running.
|
|
1636
|
+
while (this.guardAnnotations.size > 0)
|
|
1637
|
+
await Promise.all([...this.guardAnnotations]);
|
|
1638
|
+
// An annotation that starts after this point, such as one from a direct
|
|
1639
|
+
// Agent API call stop doesn't cancel, starts no write to the stopping
|
|
1640
|
+
// workspace: its stub names the audit record. A write that was past its
|
|
1641
|
+
// deadline isn't waited for here, but the workspace's own stop waits for
|
|
1642
|
+
// the mount turn it holds, bounded only by teardownTimeoutMs.
|
|
1643
|
+
this.guardSpillsClosedFor = stoppingGeneration;
|
|
1387
1644
|
// Stop modules and MCPL servers in parallel
|
|
1388
|
-
const shutdownPromises = [this.moduleRegistry.stopAll()];
|
|
1645
|
+
const shutdownPromises = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
|
|
1389
1646
|
if (this.mcplServerRegistry) {
|
|
1390
|
-
shutdownPromises.push(this.mcplServerRegistry.closeAll());
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1647
|
+
shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
|
|
1648
|
+
}
|
|
1649
|
+
// Timed-out or failed teardown retains its context and may still write.
|
|
1650
|
+
// Sync available state best-effort, but leave its store open for a retry.
|
|
1651
|
+
const shutdownErrors = [];
|
|
1652
|
+
for (const result of await Promise.allSettled(shutdownPromises)) {
|
|
1653
|
+
if (result.status === 'rejected')
|
|
1654
|
+
shutdownErrors.push(result.reason);
|
|
1655
|
+
}
|
|
1656
|
+
const teardownSucceeded = shutdownErrors.length === 0;
|
|
1657
|
+
// Those later annotations write nothing, so they finish at once; let them
|
|
1658
|
+
// record and edit their stubs before the store closes.
|
|
1659
|
+
while (this.guardAnnotations.size > 0)
|
|
1660
|
+
await Promise.all([...this.guardAnnotations]);
|
|
1393
1661
|
// Streams may queue storage repairs while being cancelled above. Retry
|
|
1394
|
-
// after their teardown, while the store is still open, before final sync
|
|
1395
|
-
|
|
1662
|
+
// after their teardown, while the store is still open, before final sync:
|
|
1663
|
+
// every live agent's guard, and every other guard that isn't idle, whose
|
|
1664
|
+
// ephemeral run or closed fork has left the agent map.
|
|
1665
|
+
for (const [guard, name] of this.guardsWithStorageWork()) {
|
|
1396
1666
|
try {
|
|
1397
|
-
|
|
1667
|
+
guard.flushUnrecorded();
|
|
1398
1668
|
}
|
|
1399
1669
|
catch (error) {
|
|
1400
|
-
console.error(`[tool-result-guard] agent=${
|
|
1670
|
+
console.error(`[tool-result-guard] agent=${name} could not finish queued storage work at stop:`, error);
|
|
1671
|
+
shutdownErrors.push(error);
|
|
1401
1672
|
}
|
|
1402
1673
|
}
|
|
1403
1674
|
// Final sync before closing
|
|
@@ -1406,10 +1677,24 @@ export class AgentFramework {
|
|
|
1406
1677
|
}
|
|
1407
1678
|
catch (error) {
|
|
1408
1679
|
console.error('Final sync error:', error);
|
|
1680
|
+
shutdownErrors.push(error);
|
|
1409
1681
|
}
|
|
1410
|
-
if (this.ownsStore) {
|
|
1411
|
-
|
|
1682
|
+
if (this.ownsStore && teardownSucceeded) {
|
|
1683
|
+
try {
|
|
1684
|
+
this.store.close();
|
|
1685
|
+
this.ownedStoreClosed = true;
|
|
1686
|
+
}
|
|
1687
|
+
catch (error) {
|
|
1688
|
+
// Chronicle may consume the handle before close's internal sync
|
|
1689
|
+
// throws. A consumed handle cannot retry the original operation.
|
|
1690
|
+
this.ownedStoreClosed = this.store.isClosed();
|
|
1691
|
+
shutdownErrors.push(error);
|
|
1692
|
+
}
|
|
1412
1693
|
}
|
|
1694
|
+
if (shutdownErrors.length === 1)
|
|
1695
|
+
throw shutdownErrors[0];
|
|
1696
|
+
if (shutdownErrors.length > 1)
|
|
1697
|
+
throw new AggregateError(shutdownErrors, `Framework shutdown failed: ${shutdownErrors.map(shutdownErrorMessage).join('; ')}`);
|
|
1413
1698
|
}
|
|
1414
1699
|
/**
|
|
1415
1700
|
* Advance queued ContextManager work without requiring a new message.
|
|
@@ -1660,6 +1945,19 @@ export class AgentFramework {
|
|
|
1660
1945
|
});
|
|
1661
1946
|
}
|
|
1662
1947
|
async runQueuedMaintenance() {
|
|
1948
|
+
// A guard whose agent has left the map gets no activation to retry its
|
|
1949
|
+
// queue; retry it here. It leaves busyGuards once nothing of it is left.
|
|
1950
|
+
const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
|
|
1951
|
+
for (const [guard, name] of this.busyGuards) {
|
|
1952
|
+
if (live.has(guard))
|
|
1953
|
+
continue;
|
|
1954
|
+
try {
|
|
1955
|
+
guard.flushUnrecorded();
|
|
1956
|
+
}
|
|
1957
|
+
catch (error) {
|
|
1958
|
+
console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1663
1961
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
1664
1962
|
try {
|
|
1665
1963
|
agent.toolResultGuard.flushUnrecorded();
|
|
@@ -1668,7 +1966,7 @@ export class AgentFramework {
|
|
|
1668
1966
|
console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
|
|
1669
1967
|
}
|
|
1670
1968
|
const cm = agent.getContextManager();
|
|
1671
|
-
const tools = this.
|
|
1969
|
+
const tools = this.compressionToolsForAgent(agent.name);
|
|
1672
1970
|
cm.setToolDefinitions(tools);
|
|
1673
1971
|
if (this.providerGateBlocked(agent.name))
|
|
1674
1972
|
return [];
|
|
@@ -1747,10 +2045,69 @@ export class AgentFramework {
|
|
|
1747
2045
|
? progress
|
|
1748
2046
|
: undefined;
|
|
1749
2047
|
}
|
|
2048
|
+
/**
|
|
2049
|
+
* A server's request or notification the host couldn't handle, answered
|
|
2050
|
+
* with a JSON-RPC error when it is a request (a notification has no one to
|
|
2051
|
+
* answer, and is logged), rather than thrown out of the connection's
|
|
2052
|
+
* listener, where it was an unhandled rejection and the server's request
|
|
2053
|
+
* waited for its timeout.
|
|
2054
|
+
* - `refused`: the listener turned it away before its handler ran; the
|
|
2055
|
+
* host is stopping, and none of it was taken. Answered -32000, "the host
|
|
2056
|
+
* is stopping", the code the push coalescer already gives a stopping
|
|
2057
|
+
* host: the server can send it again to the host's next run.
|
|
2058
|
+
* - `failed`: the handler threw, whatever it threw, `undefined` included.
|
|
2059
|
+
* While the host stops (its queue closed), that's answered -32000 too,
|
|
2060
|
+
* with the handler's own message after the stop wording; on a live host
|
|
2061
|
+
* it's a fault, answered -32603 with the handler's message. The handler
|
|
2062
|
+
* may have admitted part of the request first (an earlier message of a
|
|
2063
|
+
* batch), so nothing here claims that none of it was taken.
|
|
2064
|
+
* A request the handler already answered before it threw isn't answered
|
|
2065
|
+
* again: the failure is logged.
|
|
2066
|
+
*/
|
|
2067
|
+
refuseServerItem(method, serverId, answer, outcome) {
|
|
2068
|
+
// Optional chaining: prototype-built harnesses leave the queue unset.
|
|
2069
|
+
const stopping = outcome.kind === 'refused' || !!this.queue?.isClosed;
|
|
2070
|
+
const what = outcome.kind === 'refused' ? '' : handlerFailureText(outcome.error);
|
|
2071
|
+
const reason = outcome.kind === 'refused'
|
|
2072
|
+
? 'the host is stopping'
|
|
2073
|
+
: stopping
|
|
2074
|
+
? `the host stopped while handling this request (${what})`
|
|
2075
|
+
: what;
|
|
2076
|
+
if (outcome.kind === 'failed' && answer.answered()) {
|
|
2077
|
+
console.error(`[mcpl] ${method} from ${serverId} failed after it was answered: ${reason}`);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
console.error(outcome.kind === 'refused'
|
|
2081
|
+
? `[mcpl] ${method} from ${serverId} refused: ${reason}`
|
|
2082
|
+
: `[mcpl] ${method} from ${serverId} failed: ${reason} (part of it may already have been admitted)`);
|
|
2083
|
+
try {
|
|
2084
|
+
answer.responder?.respondError?.(stopping ? -32000 : -32603, reason);
|
|
2085
|
+
}
|
|
2086
|
+
catch {
|
|
2087
|
+
// The connection is going too; there is no one left to tell.
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
1750
2090
|
/**
|
|
1751
2091
|
* Push a process event to the queue.
|
|
2092
|
+
*
|
|
2093
|
+
* After stop() the queue is closed and this throws, so a direct caller
|
|
2094
|
+
* learns the framework is stopped — except for a tool's result. A call
|
|
2095
|
+
* still running at stop() completes later, into a callback with nothing
|
|
2096
|
+
* to catch the throw (an unhandled rejection), and nothing could take its
|
|
2097
|
+
* result anyway: the queue that would carry it to its stream is closed,
|
|
2098
|
+
* stop() is ending the streams, and the tool has already run. That
|
|
2099
|
+
* result is dropped, and the drop is logged.
|
|
1752
2100
|
*/
|
|
1753
2101
|
pushEvent(event) {
|
|
2102
|
+
if (event.type === 'tool-result' && this.queue?.isClosed) {
|
|
2103
|
+
// What an operator reconciling a dropped result needs (the resident
|
|
2104
|
+
// may run the tool again): which tool, and whether it succeeded.
|
|
2105
|
+
const outcome = event.result.success && !event.result.isError
|
|
2106
|
+
? 'succeeded'
|
|
2107
|
+
: `failed${event.result.error ? `: ${thrownText(event.result.error)}` : ''}`;
|
|
2108
|
+
console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived after stop — dropped; the tool ${outcome}`);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
1754
2111
|
this.queue.push(event);
|
|
1755
2112
|
this.emitTrace({ type: 'process:received', processEvent: event });
|
|
1756
2113
|
}
|
|
@@ -1790,6 +2147,7 @@ export class AgentFramework {
|
|
|
1790
2147
|
* Add a module at runtime.
|
|
1791
2148
|
*/
|
|
1792
2149
|
async addModule(module) {
|
|
2150
|
+
this.resetCompletedStop();
|
|
1793
2151
|
await this.moduleRegistry.addModule(module);
|
|
1794
2152
|
this.emitTrace({ type: 'module:added', moduleName: module.name });
|
|
1795
2153
|
}
|
|
@@ -1901,7 +2259,15 @@ export class AgentFramework {
|
|
|
1901
2259
|
return null;
|
|
1902
2260
|
const path = `${mount}/refs/${fileName}`;
|
|
1903
2261
|
const res = await workspace.writeBinary(path, data, mimeType);
|
|
1904
|
-
|
|
2262
|
+
if (res.success)
|
|
2263
|
+
return path;
|
|
2264
|
+
// Stored, with its disk copy failed: the workspace's tools read it
|
|
2265
|
+
// from the tree, so the reference is fetched to that path.
|
|
2266
|
+
if (res.data?.inChronicle === true) {
|
|
2267
|
+
console.error(`[refs] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${res.error ?? 'unknown error'}`);
|
|
2268
|
+
return path;
|
|
2269
|
+
}
|
|
2270
|
+
return null;
|
|
1905
2271
|
});
|
|
1906
2272
|
}
|
|
1907
2273
|
return this.referenceFetcherInstance;
|
|
@@ -2071,12 +2437,68 @@ export class AgentFramework {
|
|
|
2071
2437
|
reset: (name) => set(name, undefined),
|
|
2072
2438
|
};
|
|
2073
2439
|
}
|
|
2074
|
-
|
|
2440
|
+
/** Every guard's annotation still running, for stop() (agent-framework #277). */
|
|
2441
|
+
guardAnnotations = new Set();
|
|
2442
|
+
/** Each guard that isn't idle, by agent name, as the guard reports it. An
|
|
2443
|
+
* ephemeral run or a closed fork leaves the agent map with an annotation
|
|
2444
|
+
* maybe still running, or a stub edit, acceptance edit or audit write
|
|
2445
|
+
* queued, which maintenance and stop then retry (agent-framework #277). A
|
|
2446
|
+
* guard is here from the moment it has work left until the moment it has
|
|
2447
|
+
* none, however that comes about. */
|
|
2448
|
+
busyGuards = new Map();
|
|
2449
|
+
/** Withheld originals' writes still under way past their deadline: while
|
|
2450
|
+
* any is, the workspace isn't answering and no other withheld original's
|
|
2451
|
+
* write is started. Several can be, when writes of different agents'
|
|
2452
|
+
* batches stalled together. */
|
|
2453
|
+
stalledSpills = new Set();
|
|
2454
|
+
/** Withheld spills skipped since the current stall began. */
|
|
2455
|
+
stallSkips = 0;
|
|
2456
|
+
/** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
|
|
2457
|
+
withheldSpillDeadlineMs = WITHHELD_SPILL_DEADLINE_MS;
|
|
2458
|
+
/** The lifecycle generation whose stop() has let the running annotations
|
|
2459
|
+
* finish: a withheld original is then no longer written to a workspace
|
|
2460
|
+
* that is stopping, and its stub names the audit record. A start() after
|
|
2461
|
+
* that stop() has finished has a generation of its own, so the spill gate
|
|
2462
|
+
* is open again for an instance kept with a store it doesn't own and that
|
|
2463
|
+
* is still open (start() doesn't restore stopped modules or MCPL
|
|
2464
|
+
* connections, and refuses an owned store that stop() closed). A start()
|
|
2465
|
+
* made while a stop() is still draining isn't a supported order. */
|
|
2466
|
+
guardSpillsClosedFor = null;
|
|
2467
|
+
/** A new agent's guard: its persisted setting, the tool-results spill its
|
|
2468
|
+
* withheld originals are written to, and the set stop() waits on
|
|
2469
|
+
* (agent-framework #277). */
|
|
2470
|
+
adoptToolResultGuard(agent) {
|
|
2075
2471
|
const enabled = this.store.getStateJson(FRAMEWORK_STATE_ID)?.toolResultGuards?.[agent.name];
|
|
2076
2472
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
2077
2473
|
throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
|
|
2078
2474
|
}
|
|
2079
2475
|
agent.toolResultGuard.setOverride(enabled);
|
|
2476
|
+
const guard = agent.toolResultGuard;
|
|
2477
|
+
guard.setHost({
|
|
2478
|
+
spill: (label, text, branch, deadline) => this.spillWithheld(label, text, branch, deadline),
|
|
2479
|
+
deadline: () => Date.now() + this.withheldSpillDeadlineMs,
|
|
2480
|
+
track: (annotation) => {
|
|
2481
|
+
this.guardAnnotations.add(annotation);
|
|
2482
|
+
void annotation.finally(() => this.guardAnnotations.delete(annotation));
|
|
2483
|
+
},
|
|
2484
|
+
idle: (idle) => {
|
|
2485
|
+
if (idle)
|
|
2486
|
+
this.busyGuards.delete(guard);
|
|
2487
|
+
else
|
|
2488
|
+
this.busyGuards.set(guard, agent.name);
|
|
2489
|
+
},
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
/** Each guard that may have queued storage work, once, by agent name: the
|
|
2493
|
+
* live agents' and every other that isn't idle. */
|
|
2494
|
+
guardsWithStorageWork() {
|
|
2495
|
+
const guards = new Map();
|
|
2496
|
+
for (const agent of this.agents.values())
|
|
2497
|
+
guards.set(agent.toolResultGuard, agent.name);
|
|
2498
|
+
for (const [guard, name] of this.busyGuards)
|
|
2499
|
+
if (!guards.has(guard))
|
|
2500
|
+
guards.set(guard, name);
|
|
2501
|
+
return guards;
|
|
2080
2502
|
}
|
|
2081
2503
|
buildThinkTool(policy) {
|
|
2082
2504
|
return {
|
|
@@ -2146,7 +2568,7 @@ export class AgentFramework {
|
|
|
2146
2568
|
: t);
|
|
2147
2569
|
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
2148
2570
|
}
|
|
2149
|
-
return this.getAllTools().map((tool) => {
|
|
2571
|
+
return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName).config.cataloguePath) : [])].map((tool) => {
|
|
2150
2572
|
if (tool.name === 'think') {
|
|
2151
2573
|
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
2152
2574
|
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy);
|
|
@@ -2154,6 +2576,54 @@ export class AgentFramework {
|
|
|
2154
2576
|
return tool;
|
|
2155
2577
|
});
|
|
2156
2578
|
}
|
|
2579
|
+
/** Available is independent of presentation visibility; execution callers keep this surface. */
|
|
2580
|
+
availableToolsForPresentation(agentName, snapshot) {
|
|
2581
|
+
const agent = this.agents.get(agentName);
|
|
2582
|
+
if (!agent)
|
|
2583
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
2584
|
+
const tools = this.getToolsForAgent(agentName, snapshot).filter(t => agent.canUseTool(t.name));
|
|
2585
|
+
if (agent.proseRouting === 'explicit' && agent.canUseTool(PROSE_HELP_TOOL.name))
|
|
2586
|
+
tools.push(PROSE_HELP_TOOL);
|
|
2587
|
+
return tools;
|
|
2588
|
+
}
|
|
2589
|
+
inspectToolPresentation(agentName, snapshot) {
|
|
2590
|
+
const presentation = this.toolPresentations.get(agentName);
|
|
2591
|
+
if (!presentation)
|
|
2592
|
+
return null;
|
|
2593
|
+
const tools = this.availableToolsForPresentation(agentName, snapshot);
|
|
2594
|
+
const sources = new Map();
|
|
2595
|
+
for (const tool of this.moduleRegistry.getAllTools()) {
|
|
2596
|
+
sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`);
|
|
2597
|
+
}
|
|
2598
|
+
// Use registered server prefixes, not a guess from a conventional mcpl-- name.
|
|
2599
|
+
for (const tool of tools) {
|
|
2600
|
+
for (const config of this.mcplServerConfigs.values()) {
|
|
2601
|
+
if (tool.name.startsWith((config.toolPrefix ?? `mcpl--${config.id}`) + '--')) {
|
|
2602
|
+
sources.set(tool.name, `MCPL server: ${config.id}`);
|
|
2603
|
+
break;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (!sources.has(tool.name))
|
|
2607
|
+
sources.set(tool.name, 'Framework');
|
|
2608
|
+
}
|
|
2609
|
+
return presentation.resolve(tools, sources);
|
|
2610
|
+
}
|
|
2611
|
+
advertisedToolsForAgent(agentName, snapshot) {
|
|
2612
|
+
return this.inspectToolPresentation(agentName, snapshot)?.advertised
|
|
2613
|
+
?? this.availableToolsForPresentation(agentName, snapshot);
|
|
2614
|
+
}
|
|
2615
|
+
/** Compression can revisit calls to hidden tools; never apply visibility here. */
|
|
2616
|
+
compressionToolsForAgent(agentName, snapshot) {
|
|
2617
|
+
return this.inspectToolPresentation(agentName, snapshot)?.available
|
|
2618
|
+
?? this.availableToolsForPresentation(agentName, snapshot);
|
|
2619
|
+
}
|
|
2620
|
+
editToolPresentation(agentName, call) {
|
|
2621
|
+
const presentation = this.toolPresentations.get(agentName);
|
|
2622
|
+
const agent = this.agents.get(agentName);
|
|
2623
|
+
if (!presentation || !agent?.canUseTool(call.name))
|
|
2624
|
+
return { success: false, isError: true, error: 'Tool presentation is not enabled for this caller' };
|
|
2625
|
+
return presentation.edit(call.name, call.input, this.availableToolsForPresentation(agentName));
|
|
2626
|
+
}
|
|
2157
2627
|
/**
|
|
2158
2628
|
* The tools one agent is shown at inference: its surface (the subconscious
|
|
2159
2629
|
* has its own), less what its permissions deny, plus — for explicit-mode
|
|
@@ -2162,10 +2632,7 @@ export class AgentFramework {
|
|
|
2162
2632
|
* listing both use this, so the listing cannot drift from the model's view.
|
|
2163
2633
|
*/
|
|
2164
2634
|
agentToolSurface(agent, snapshot) {
|
|
2165
|
-
|
|
2166
|
-
if (agent.proseRouting === 'explicit')
|
|
2167
|
-
tools.push(PROSE_HELP_TOOL);
|
|
2168
|
-
return tools;
|
|
2635
|
+
return this.advertisedToolsForAgent(agent.name, snapshot);
|
|
2169
2636
|
}
|
|
2170
2637
|
getAgentRuntimeSettings(agentName) {
|
|
2171
2638
|
const agent = this.agents.get(agentName);
|
|
@@ -2926,7 +3393,13 @@ export class AgentFramework {
|
|
|
2926
3393
|
if (!agent) {
|
|
2927
3394
|
throw new Error(`Agent not found: ${agentName}`);
|
|
2928
3395
|
}
|
|
2929
|
-
const
|
|
3396
|
+
const presentation = this.inspectToolPresentation(agentName);
|
|
3397
|
+
const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName);
|
|
3398
|
+
const capture = (request) => {
|
|
3399
|
+
if (presentation)
|
|
3400
|
+
this.presentationPreviews.set(request, presentation);
|
|
3401
|
+
return request;
|
|
3402
|
+
};
|
|
2930
3403
|
// An explicit budget compiles against a HYPOTHETICAL window instead of the
|
|
2931
3404
|
// agent's live one. That also suppresses transition-settling in
|
|
2932
3405
|
// compileWithInjections (which only settles when no budget is passed), so
|
|
@@ -2934,7 +3407,7 @@ export class AgentFramework {
|
|
|
2934
3407
|
// Default: no dynamic injection gathering → fully transparent (no
|
|
2935
3408
|
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
2936
3409
|
if (!opts?.injections) {
|
|
2937
|
-
return agent.buildActivationRequest(tools, undefined, opts?.budget);
|
|
3410
|
+
return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
|
|
2938
3411
|
}
|
|
2939
3412
|
// Full-fidelity path: mirrors startAgentStream's injection gathering.
|
|
2940
3413
|
// NOT transparent — see the doc comment above.
|
|
@@ -2964,7 +3437,7 @@ export class AgentFramework {
|
|
|
2964
3437
|
console.error('beforeInference hook error (preview):', error);
|
|
2965
3438
|
}
|
|
2966
3439
|
}
|
|
2967
|
-
return agent.buildActivationRequest(tools, injections, opts?.budget);
|
|
3440
|
+
return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
|
|
2968
3441
|
}
|
|
2969
3442
|
/**
|
|
2970
3443
|
* Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
|
|
@@ -3341,7 +3814,7 @@ export class AgentFramework {
|
|
|
3341
3814
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3342
3815
|
});
|
|
3343
3816
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
3344
|
-
this.
|
|
3817
|
+
this.adoptToolResultGuard(agent);
|
|
3345
3818
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
3346
3819
|
this.ephemeralCandidates.set(agent, contextManager);
|
|
3347
3820
|
const cleanup = () => {
|
|
@@ -3393,10 +3866,20 @@ export class AgentFramework {
|
|
|
3393
3866
|
run.toolCallsCount += count;
|
|
3394
3867
|
run.lastActivity = Date.now();
|
|
3395
3868
|
}
|
|
3396
|
-
/**
|
|
3397
|
-
*
|
|
3398
|
-
*
|
|
3399
|
-
|
|
3869
|
+
/** A logical turn's end, for every agent: it closes the agent's open
|
|
3870
|
+
* logical turn when it is the settling stream's own (openLogical), and
|
|
3871
|
+
* settles an active ephemeral run. The run's tool-call count comes from
|
|
3872
|
+
* its own counter — the single source of truth, which unlike a
|
|
3873
|
+
* per-stream local survives budget restarts. */
|
|
3874
|
+
settleAgent(agentName, outcome,
|
|
3875
|
+
/** The settling stream's logical turn, when that stream still owns the
|
|
3876
|
+
* agent: it ends here, so the agent's next fresh start opens a new one.
|
|
3877
|
+
* A settle from a stream a newer one replaced closes nothing. */
|
|
3878
|
+
closes) {
|
|
3879
|
+
// This identity check is the only guard against a stale settle closing
|
|
3880
|
+
// a newer turn (no test reaches one); keep it when editing call sites.
|
|
3881
|
+
if (closes && this.openLogical.get(agentName) === closes)
|
|
3882
|
+
this.openLogical.delete(agentName);
|
|
3400
3883
|
const run = this.ephemeralRuns.get(agentName);
|
|
3401
3884
|
if (!run)
|
|
3402
3885
|
return;
|
|
@@ -3525,6 +4008,9 @@ export class AgentFramework {
|
|
|
3525
4008
|
// generation mismatch and leave any future owner untouched.
|
|
3526
4009
|
this.activeStreams.delete(agent.name);
|
|
3527
4010
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
4011
|
+
this.turnRouting.delete(agent.name);
|
|
4012
|
+
this.proseSpeechChains.delete(agent.name);
|
|
4013
|
+
this.openLogical.delete(agent.name);
|
|
3528
4014
|
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
3529
4015
|
// deregistered, so disposal must release EventGate liveness itself.
|
|
3530
4016
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
@@ -5361,7 +5847,7 @@ export class AgentFramework {
|
|
|
5361
5847
|
viewFilter: (message) => !message.metadata?.tuneOut,
|
|
5362
5848
|
});
|
|
5363
5849
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
5364
|
-
this.
|
|
5850
|
+
this.adoptToolResultGuard(agent);
|
|
5365
5851
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
5366
5852
|
this.sharedSlotAgents.add(agent);
|
|
5367
5853
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -5431,7 +5917,7 @@ export class AgentFramework {
|
|
|
5431
5917
|
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
5432
5918
|
};
|
|
5433
5919
|
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
5434
|
-
this.
|
|
5920
|
+
this.adoptToolResultGuard(agent);
|
|
5435
5921
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
5436
5922
|
this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
|
|
5437
5923
|
this.agents.set(name, agent);
|
|
@@ -5496,6 +5982,10 @@ export class AgentFramework {
|
|
|
5496
5982
|
const scriptWaiter = this.scriptToolWaiters.get(event.callId);
|
|
5497
5983
|
if (scriptWaiter) {
|
|
5498
5984
|
this.scriptToolWaiters.delete(event.callId);
|
|
5985
|
+
// RFC-007: the inner call's terminal. Also reached when the script
|
|
5986
|
+
// was killed while the call ran: the result reaches no one, but the
|
|
5987
|
+
// call did finish.
|
|
5988
|
+
this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
|
|
5499
5989
|
scriptWaiter(event.result);
|
|
5500
5990
|
return;
|
|
5501
5991
|
}
|
|
@@ -5522,6 +6012,38 @@ export class AgentFramework {
|
|
|
5522
6012
|
result: event.result,
|
|
5523
6013
|
});
|
|
5524
6014
|
}
|
|
6015
|
+
// The round's last pending result: let modules catch up with what the
|
|
6016
|
+
// batch's tools did (the workspace scans `on-agent-action` mounts) before
|
|
6017
|
+
// anything continues the turn. Bounded and fail-open; the agent may be
|
|
6018
|
+
// reset or cancelled meanwhile, so its state is checked again after.
|
|
6019
|
+
if (agent && agent.state.status === 'waiting_for_tools'
|
|
6020
|
+
&& agent.state.pending.size === 1 && agent.state.pending.has(event.callId)) {
|
|
6021
|
+
// The call this result answers, as this round holds it: a new round
|
|
6022
|
+
// that reuses the call's id holds a new entry for it.
|
|
6023
|
+
const owed = agent.state.pending.get(event.callId);
|
|
6024
|
+
const failures = await this.moduleRegistry.notifyToolBatchComplete(event.agentName);
|
|
6025
|
+
for (const failure of failures) {
|
|
6026
|
+
this.emitTrace({ type: 'module:batch_hook_failed', agentName: event.agentName, module: failure.module, error: failure.error });
|
|
6027
|
+
}
|
|
6028
|
+
// The turn may have been reset, cancelled or replaced while the hook
|
|
6029
|
+
// ran, even back to waiting on a new batch, whose calls may reuse this
|
|
6030
|
+
// one's id: this result is still owed only if this agent still waits
|
|
6031
|
+
// on this round's call.
|
|
6032
|
+
const after = agent.state;
|
|
6033
|
+
if (this.agents.get(event.agentName) !== agent
|
|
6034
|
+
|| after.status !== 'waiting_for_tools' || after.pending.get(event.callId) !== owed) {
|
|
6035
|
+
console.warn(`[framework] Dropping tool-result for agent '${event.agentName}' (callId=${event.callId}): ` +
|
|
6036
|
+
`its turn changed while modules caught up with the batch (status '${after.status}').`);
|
|
6037
|
+
this.emitTrace({
|
|
6038
|
+
type: 'tool:result_dropped',
|
|
6039
|
+
agentName: event.agentName,
|
|
6040
|
+
callId: event.callId,
|
|
6041
|
+
agentStatus: agent.state.status,
|
|
6042
|
+
result: event.result,
|
|
6043
|
+
});
|
|
6044
|
+
return;
|
|
6045
|
+
}
|
|
6046
|
+
}
|
|
5525
6047
|
if (agent && agent.state.status === 'waiting_for_tools') {
|
|
5526
6048
|
agent.provideToolResult(event.callId, event.result);
|
|
5527
6049
|
// Check if agent is now ready (state may have changed after provideToolResult)
|
|
@@ -5530,8 +6052,9 @@ export class AgentFramework {
|
|
|
5530
6052
|
if (currentState.status === 'ready') {
|
|
5531
6053
|
// Flush pending assistant blocks (tool_use + preamble text) to context
|
|
5532
6054
|
const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
|
|
6055
|
+
const turnRowMetadata = this.silentTurnRowMetadata(agent.name);
|
|
5533
6056
|
if (pendingBlocks) {
|
|
5534
|
-
agent.addAssistantResponse(pendingBlocks);
|
|
6057
|
+
agent.addAssistantResponse(pendingBlocks, turnRowMetadata);
|
|
5535
6058
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
5536
6059
|
}
|
|
5537
6060
|
// Effective inline cap: agent_settings override → durable config →
|
|
@@ -5545,7 +6068,7 @@ export class AgentFramework {
|
|
|
5545
6068
|
// divergence breaks the compile prefix).
|
|
5546
6069
|
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
|
|
5547
6070
|
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
|
|
5548
|
-
agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
|
|
6071
|
+
agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata);
|
|
5549
6072
|
// Flush any messages that were deferred while this turn was in
|
|
5550
6073
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
5551
6074
|
// framework-level (module messages, push events, subagent return
|
|
@@ -5730,7 +6253,7 @@ export class AgentFramework {
|
|
|
5730
6253
|
// agent must be wakeable the moment it goes idle. (Idempotent;
|
|
5731
6254
|
// driveStream's finally is the backstop.)
|
|
5732
6255
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
5733
|
-
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
6256
|
+
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical);
|
|
5734
6257
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
5735
6258
|
}
|
|
5736
6259
|
else if (overBudget || overPhysical) {
|
|
@@ -5772,6 +6295,7 @@ export class AgentFramework {
|
|
|
5772
6295
|
timestamp: Date.now(),
|
|
5773
6296
|
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
5774
6297
|
ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
|
|
6298
|
+
silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
|
|
5775
6299
|
});
|
|
5776
6300
|
}
|
|
5777
6301
|
else if (currentState.stream) {
|
|
@@ -5788,7 +6312,8 @@ export class AgentFramework {
|
|
|
5788
6312
|
});
|
|
5789
6313
|
}
|
|
5790
6314
|
else {
|
|
5791
|
-
// Non-streaming fallback: schedule re-inference
|
|
6315
|
+
// Non-streaming fallback: schedule re-inference, which continues
|
|
6316
|
+
// this stream's logical turn (openLogical).
|
|
5792
6317
|
this.pendingRequests.push({
|
|
5793
6318
|
agentName: agent.name,
|
|
5794
6319
|
reason: 'tool_results_ready',
|
|
@@ -5910,6 +6435,16 @@ export class AgentFramework {
|
|
|
5910
6435
|
* This replaces the old MCPLModule.onProcess() message conversion.
|
|
5911
6436
|
*/
|
|
5912
6437
|
async handleMcplChannelIncoming(event) {
|
|
6438
|
+
// Same backstop as the push lane: no row, no wake, no fork spawned for a
|
|
6439
|
+
// message with nothing visible in it.
|
|
6440
|
+
if (isVisiblyEmptyContent(event.content)) {
|
|
6441
|
+
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
|
|
6442
|
+
this.emitTrace({
|
|
6443
|
+
type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
|
|
6444
|
+
channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
|
|
6445
|
+
});
|
|
6446
|
+
return undefined;
|
|
6447
|
+
}
|
|
5913
6448
|
const metadata = {
|
|
5914
6449
|
...event.metadata,
|
|
5915
6450
|
...(event.eventId ? { eventId: event.eventId } : {}),
|
|
@@ -6154,7 +6689,10 @@ export class AgentFramework {
|
|
|
6154
6689
|
// seeding again would stack another template copy on top of it.
|
|
6155
6690
|
const { messages: existing } = await contextManager.compile();
|
|
6156
6691
|
if (existing.length === 0) {
|
|
6157
|
-
|
|
6692
|
+
// The copy is the fork's own, and the template's annotation edits only
|
|
6693
|
+
// the template's message: seed only once each of the template's
|
|
6694
|
+
// annotations has finished.
|
|
6695
|
+
const { messages: compiled } = await template.toolResultGuard.afterAnnotations(() => template.getContextManager().compile());
|
|
6158
6696
|
for (const msg of compiled) {
|
|
6159
6697
|
const participant = msg.participant === template.name ? name : msg.participant;
|
|
6160
6698
|
contextManager.addMessage(participant, msg.content);
|
|
@@ -6162,7 +6700,7 @@ export class AgentFramework {
|
|
|
6162
6700
|
}
|
|
6163
6701
|
const config = { ...templateConfig, name, strategy: undefined };
|
|
6164
6702
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
6165
|
-
this.
|
|
6703
|
+
this.adoptToolResultGuard(agent);
|
|
6166
6704
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
6167
6705
|
this.agents.set(name, agent);
|
|
6168
6706
|
this.agentConfigs.set(name, config);
|
|
@@ -6198,6 +6736,9 @@ export class AgentFramework {
|
|
|
6198
6736
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
6199
6737
|
const agent = this.agents.get(agentName);
|
|
6200
6738
|
this.agents.delete(agentName);
|
|
6739
|
+
this.turnRouting.delete(agentName);
|
|
6740
|
+
this.proseSpeechChains.delete(agentName);
|
|
6741
|
+
this.openLogical.delete(agentName);
|
|
6201
6742
|
this.toolImageLedgers.delete(agentName);
|
|
6202
6743
|
this.agentConfigs.delete(agentName);
|
|
6203
6744
|
this.conversationAgentHomes.delete(agentName);
|
|
@@ -6511,7 +7052,7 @@ export class AgentFramework {
|
|
|
6511
7052
|
}
|
|
6512
7053
|
if (typeof message.timestamp !== 'string')
|
|
6513
7054
|
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
6514
|
-
validateCoalescedContent(message.content);
|
|
7055
|
+
validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
|
|
6515
7056
|
const c = message.coalesce;
|
|
6516
7057
|
const result = await this.pushCoalescer.accept({
|
|
6517
7058
|
serverId,
|
|
@@ -6540,7 +7081,11 @@ export class AgentFramework {
|
|
|
6540
7081
|
throw new CoalesceError('eventId', 'eventId is required');
|
|
6541
7082
|
if (typeof params.timestamp !== 'string')
|
|
6542
7083
|
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
6543
|
-
validateCoalescedContent(params.payload?.content
|
|
7084
|
+
validateCoalescedContent(params.payload?.content, undefined, {
|
|
7085
|
+
allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
|
|
7086
|
+
serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
|
|
7087
|
+
}),
|
|
7088
|
+
});
|
|
6544
7089
|
const c = params.coalesce;
|
|
6545
7090
|
if (c.channelId)
|
|
6546
7091
|
this.assertChannelScopedPush(serverId, c.channelId);
|
|
@@ -6720,6 +7265,7 @@ export class AgentFramework {
|
|
|
6720
7265
|
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
6721
7266
|
addressed: isAddressedMessage(occ.tags, origin),
|
|
6722
7267
|
coalescingSubject: subject,
|
|
7268
|
+
coalescingBatch: true,
|
|
6723
7269
|
});
|
|
6724
7270
|
}
|
|
6725
7271
|
}
|
|
@@ -6848,6 +7394,16 @@ export class AgentFramework {
|
|
|
6848
7394
|
* Convert an MCPL push event to a context message.
|
|
6849
7395
|
*/
|
|
6850
7396
|
handleMcplPushEvent(event) {
|
|
7397
|
+
// Backstop for the MCPL boundary's empty-content rejection, covering
|
|
7398
|
+
// events that never crossed it (module-emitted, coalescer deliveries):
|
|
7399
|
+
// content with nothing visible stores no row and queues no wake — the
|
|
7400
|
+
// model would wake to `[Continue]` or an older message re-presented as
|
|
7401
|
+
// the newest, a wake with no visible cause.
|
|
7402
|
+
if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
|
|
7403
|
+
console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
|
|
7404
|
+
this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
|
|
7405
|
+
return undefined;
|
|
7406
|
+
}
|
|
6851
7407
|
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
6852
7408
|
if (triggerChannel && this.channelRegistry) {
|
|
6853
7409
|
this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label, triggerChannel.metadata);
|
|
@@ -6893,11 +7449,7 @@ export class AgentFramework {
|
|
|
6893
7449
|
// Accept the no-message path only for the heartbeat feature's own exact
|
|
6894
7450
|
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
6895
7451
|
// content merely by setting `origin.silent`.
|
|
6896
|
-
const silentHeartbeat = event
|
|
6897
|
-
event.featureSet === 'heartbeat' &&
|
|
6898
|
-
event.origin?.source === 'heartbeat' &&
|
|
6899
|
-
event.origin?.silent === true &&
|
|
6900
|
-
content.length === 0;
|
|
7452
|
+
const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
|
|
6901
7453
|
const placement = { agent: '' };
|
|
6902
7454
|
if (!silentHeartbeat) {
|
|
6903
7455
|
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
@@ -6925,6 +7477,7 @@ export class AgentFramework {
|
|
|
6925
7477
|
suppressProse: true,
|
|
6926
7478
|
ephemeralSystemPrompt: '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
|
|
6927
7479
|
'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
|
|
7480
|
+
silentHeartbeat: { eventId: event.eventId, serverId: event.serverId },
|
|
6928
7481
|
} : {}),
|
|
6929
7482
|
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
6930
7483
|
});
|
|
@@ -6932,6 +7485,22 @@ export class AgentFramework {
|
|
|
6932
7485
|
}
|
|
6933
7486
|
return placement.agent ? placement : undefined;
|
|
6934
7487
|
}
|
|
7488
|
+
/**
|
|
7489
|
+
* Whether a batched gate wake's addressed route may become `agentName`'s
|
|
7490
|
+
* speech locus. The gate broadcasts to every agent, conversation forks
|
|
7491
|
+
* included. A fork speaks only in its home channel, and a channel bound to
|
|
7492
|
+
* a fork is that fork's conversation, so no other agent takes it.
|
|
7493
|
+
*/
|
|
7494
|
+
mayTakeGateRoute(agentName, routeChannelId) {
|
|
7495
|
+
const home = this.conversationAgentHomes.get(agentName);
|
|
7496
|
+
if (home !== undefined)
|
|
7497
|
+
return home === routeChannelId;
|
|
7498
|
+
for (const boundChannel of this.conversationAgentHomes.values()) {
|
|
7499
|
+
if (boundChannel === routeChannelId)
|
|
7500
|
+
return false;
|
|
7501
|
+
}
|
|
7502
|
+
return true;
|
|
7503
|
+
}
|
|
6935
7504
|
/**
|
|
6936
7505
|
* Derive the MCPL composite channel id (the outbound routing locus) for a
|
|
6937
7506
|
* push event from its server-defined `origin`, if it carries one.
|
|
@@ -7180,21 +7749,34 @@ export class AgentFramework {
|
|
|
7180
7749
|
// and counterparty must come from the same message, or a batch of
|
|
7181
7750
|
// "ambient from A, then addressed from B" would report B's channel
|
|
7182
7751
|
// with A's author (review finding on the provenance change).
|
|
7752
|
+
//
|
|
7753
|
+
// "Most recent" is by the EVENT's time (wakeAt, else the request's own
|
|
7754
|
+
// timestamp), not queue order. A gate wake is queued when its debounce
|
|
7755
|
+
// flushes, which can be long after its event: an addressed message
|
|
7756
|
+
// held in the gate while the agent was busy lands BEHIND a newer
|
|
7757
|
+
// addressed message's direct wake, and queue order would hand the turn
|
|
7758
|
+
// to the older one. Ties keep the later-queued request, as before.
|
|
7759
|
+
const eventAt = (r) => r.wakeAt ?? r.timestamp;
|
|
7183
7760
|
let ambientReq;
|
|
7184
7761
|
let addressedReq;
|
|
7185
7762
|
for (const r of requests) {
|
|
7186
7763
|
if (!r.channelId)
|
|
7187
7764
|
continue;
|
|
7188
|
-
ambientReq
|
|
7189
|
-
|
|
7765
|
+
if (!ambientReq || eventAt(r) >= eventAt(ambientReq))
|
|
7766
|
+
ambientReq = r;
|
|
7767
|
+
if (r.addressed && (!addressedReq || eventAt(r) >= eventAt(addressedReq)))
|
|
7190
7768
|
addressedReq = r;
|
|
7191
7769
|
}
|
|
7192
7770
|
const channelReq = addressedReq ?? ambientReq;
|
|
7193
|
-
// Telemetry provenance (who/where woke the agent)
|
|
7194
|
-
//
|
|
7195
|
-
//
|
|
7196
|
-
//
|
|
7197
|
-
|
|
7771
|
+
// Telemetry provenance (who/where woke the agent). An ADDRESSED winner
|
|
7772
|
+
// is its own provenance, even when it names no author (a push event
|
|
7773
|
+
// can route without an author id): borrowing another request's author
|
|
7774
|
+
// would credit the turn to someone who did not address the agent.
|
|
7775
|
+
// Otherwise from the routing winner when it carries one; else from the
|
|
7776
|
+
// most recent request that does (ambient gate wakes name a counterparty
|
|
7777
|
+
// without setting a locus), ordered by the event's own time.
|
|
7778
|
+
let provReq = addressedReq
|
|
7779
|
+
?? (channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined);
|
|
7198
7780
|
if (!provReq) {
|
|
7199
7781
|
for (const r of requests) {
|
|
7200
7782
|
if (!r.counterparty && !r.wakeChannelId)
|
|
@@ -7206,10 +7788,19 @@ export class AgentFramework {
|
|
|
7206
7788
|
// A silent tick never suppresses a genuine coalesced wake. If any
|
|
7207
7789
|
// ordinary request shares this batch, the ordinary turn semantics win.
|
|
7208
7790
|
const silentOnly = requests.every((r) => r.suppressProse === true);
|
|
7791
|
+
// RFC-006 §5: when every cause is a deferred batch, the turn's content
|
|
7792
|
+
// exists only once assembly renders it. Recorded for the whole batch
|
|
7793
|
+
// (not inherited from requests[0]), so a requeued trigger stays honest.
|
|
7794
|
+
const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
|
|
7209
7795
|
await this.startAgentStream(agent, {
|
|
7210
7796
|
...trigger,
|
|
7797
|
+
coalescingBatch: batchOnly || undefined,
|
|
7798
|
+
coalescingBatchSubjects: batchOnly
|
|
7799
|
+
? [...new Set(requests.flatMap((r) => r.coalescingBatchSubjects ?? [r.coalescingSubject]))]
|
|
7800
|
+
: undefined,
|
|
7211
7801
|
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
|
|
7212
7802
|
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
|
|
7803
|
+
silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
|
|
7213
7804
|
channelId: channelReq?.channelId,
|
|
7214
7805
|
addressed: addressedReq !== undefined,
|
|
7215
7806
|
// A context-budget restart continues the same logical turn: it keeps
|
|
@@ -7222,22 +7813,33 @@ export class AgentFramework {
|
|
|
7222
7813
|
});
|
|
7223
7814
|
}
|
|
7224
7815
|
}
|
|
7225
|
-
/**
|
|
7226
|
-
|
|
7816
|
+
/**
|
|
7817
|
+
* Metadata for rows the agent's current turn stores: a silent heartbeat
|
|
7818
|
+
* tick stamps each of them (assistant responses, tool_result rows) so they
|
|
7819
|
+
* stay identifiable after the turn — request builds key the tick separator
|
|
7820
|
+
* on it, and a later retention policy can find or collapse them. The stamp
|
|
7821
|
+
* names the agent: residents share one message slot and a broadcast tick
|
|
7822
|
+
* reaches each of them with the same eventId. Undefined for ordinary
|
|
7823
|
+
* turns, whose rows are stored exactly as before.
|
|
7824
|
+
*/
|
|
7825
|
+
silentTurnRowMetadata(agentName) {
|
|
7826
|
+
const tick = this.activeTurnTriggers.get(agentName)?.silentHeartbeat;
|
|
7827
|
+
const stamp = tick
|
|
7828
|
+
? { eventId: tick.eventId, serverId: tick.serverId, agentName }
|
|
7829
|
+
: undefined;
|
|
7830
|
+
return stamp ? { silentHeartbeat: stamp } : undefined;
|
|
7831
|
+
}
|
|
7832
|
+
/** Record plain-prose segments this turn suppressed. */
|
|
7833
|
+
recordProseSuppression(routing, count) {
|
|
7227
7834
|
if (count <= 0)
|
|
7228
7835
|
return;
|
|
7229
|
-
|
|
7836
|
+
routing.logical.suppressed += count;
|
|
7230
7837
|
}
|
|
7231
7838
|
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
7232
|
-
recordProseDelivery(
|
|
7839
|
+
recordProseDelivery(routing, outcome) {
|
|
7233
7840
|
if (!outcome?.delivered)
|
|
7234
7841
|
return;
|
|
7235
|
-
|
|
7236
|
-
if (!list) {
|
|
7237
|
-
list = [];
|
|
7238
|
-
this.turnProseDeliveries.set(agentName, list);
|
|
7239
|
-
}
|
|
7240
|
-
list.push(outcome.channelId);
|
|
7842
|
+
routing.logical.deliveries.push(outcome.channelId);
|
|
7241
7843
|
}
|
|
7242
7844
|
/**
|
|
7243
7845
|
* Append the turn's `[delivered]` receipt: one compact system message
|
|
@@ -7252,13 +7854,13 @@ export class AgentFramework {
|
|
|
7252
7854
|
* delivered nothing. Failures are already marked separately
|
|
7253
7855
|
* ([discord-send-failed]); this is the success half.
|
|
7254
7856
|
*/
|
|
7255
|
-
appendProseDeliveryReceipt(agent) {
|
|
7256
|
-
const list =
|
|
7257
|
-
const suppressed =
|
|
7258
|
-
if (
|
|
7857
|
+
appendProseDeliveryReceipt(agent, routing) {
|
|
7858
|
+
const list = routing.logical.deliveries;
|
|
7859
|
+
const suppressed = routing.logical.suppressed;
|
|
7860
|
+
if (list.length === 0 && suppressed === 0)
|
|
7259
7861
|
return;
|
|
7260
|
-
|
|
7261
|
-
|
|
7862
|
+
routing.logical.deliveries = [];
|
|
7863
|
+
routing.logical.suppressed = 0;
|
|
7262
7864
|
const seen = new Set();
|
|
7263
7865
|
const shown = [];
|
|
7264
7866
|
for (const id of list ?? []) {
|
|
@@ -7367,6 +7969,46 @@ export class AgentFramework {
|
|
|
7367
7969
|
console.error('recordChannelAutoOpenNotice failed:', err);
|
|
7368
7970
|
}
|
|
7369
7971
|
}
|
|
7972
|
+
/**
|
|
7973
|
+
* A `!` continuation the turn asked for (`>>#x !`, `>>>#x !`): start another
|
|
7974
|
+
* turn now instead of pausing until the next external event. It reads the
|
|
7975
|
+
* logical turn of the stream that asked (shared with a retry or a
|
|
7976
|
+
* re-inference that continues it), once its deliveries have run, when the
|
|
7977
|
+
* turn completes or a tool ends it. A stream that ends in an error asks for
|
|
7978
|
+
* none itself, but a framework retry that follows it carries the request;
|
|
7979
|
+
* a turn abandoned by a quiesce asks for none.
|
|
7980
|
+
*/
|
|
7981
|
+
requestProseContinuation(agent, routing) {
|
|
7982
|
+
// The flag may be shared with an earlier stream of the same logical
|
|
7983
|
+
// turn (LogicalTurn); its deliveries are on the agent's chain ahead of
|
|
7984
|
+
// this stream's, so by the time this check runs, they have run.
|
|
7985
|
+
if (!routing.logical.continuation)
|
|
7986
|
+
return;
|
|
7987
|
+
routing.logical.continuation = false;
|
|
7988
|
+
// An explicit turn's check can run after its stream has returned. A
|
|
7989
|
+
// stopped framework, or an agent no longer registered under its name (a
|
|
7990
|
+
// disposed fork or ephemeral run), takes no new turn from it.
|
|
7991
|
+
if (this.providerAdmissionClosed || this.agents.get(agent.name) !== agent) {
|
|
7992
|
+
console.error(`[prose] ${agent.name}: '!' continuation dropped — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
|
|
7993
|
+
return;
|
|
7994
|
+
}
|
|
7995
|
+
console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
|
|
7996
|
+
this.pendingRequests.push({
|
|
7997
|
+
agentName: agent.name,
|
|
7998
|
+
reason: 'prose-continuation',
|
|
7999
|
+
source: 'framework',
|
|
8000
|
+
timestamp: Date.now(),
|
|
8001
|
+
});
|
|
8002
|
+
}
|
|
8003
|
+
/** The agent's routing state for its current turn, made when it has none. */
|
|
8004
|
+
routingOf(agentName) {
|
|
8005
|
+
let routing = this.turnRouting.get(agentName);
|
|
8006
|
+
if (!routing) {
|
|
8007
|
+
routing = freshTurnRouting();
|
|
8008
|
+
this.turnRouting.set(agentName, routing);
|
|
8009
|
+
}
|
|
8010
|
+
return routing;
|
|
8011
|
+
}
|
|
7370
8012
|
/**
|
|
7371
8013
|
* Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
|
|
7372
8014
|
* Every prose segment of an `explicit`-mode agent flows through here:
|
|
@@ -7375,36 +8017,58 @@ export class AgentFramework {
|
|
|
7375
8017
|
* nothing is ever sent to a destination the model did not name (directly
|
|
7376
8018
|
* or via the turn's sticky target).
|
|
7377
8019
|
*/
|
|
7378
|
-
async deliverProse(agent, rawText) {
|
|
7379
|
-
//
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
8020
|
+
async deliverProse(agent, rawText, round) {
|
|
8021
|
+
// The direct entry delivers under the live turn's routing.
|
|
8022
|
+
await this.deliverAdmittedProse(agent, this.admitProse(rawText), {
|
|
8023
|
+
keepPrivate: round?.keepPrivate,
|
|
8024
|
+
routing: this.routingOf(agent.name),
|
|
8025
|
+
});
|
|
8026
|
+
}
|
|
8027
|
+
/**
|
|
8028
|
+
* Explicit prose's envelopes, each `>>` target resolved now, once. A live
|
|
8029
|
+
* round is admitted at its boundary: its turn's destination is decided from
|
|
8030
|
+
* these resolutions (roundDestination), and its queued delivery posts to
|
|
8031
|
+
* them, however the registry's names change before it runs.
|
|
8032
|
+
*/
|
|
8033
|
+
admitProse(rawText) {
|
|
8034
|
+
return splitExplicitEnvelopes(rawText).map((text) => {
|
|
8035
|
+
const parsed = parseProsePrefix(text);
|
|
8036
|
+
if (parsed.kind !== 'target')
|
|
8037
|
+
return { text };
|
|
8038
|
+
try {
|
|
8039
|
+
return { text, resolved: this.channelRegistry.resolveProseTarget(parsed.target) };
|
|
7395
8040
|
}
|
|
7396
|
-
|
|
8041
|
+
catch (err) {
|
|
8042
|
+
// Admission runs in the stream's event loop, so a resolver that
|
|
8043
|
+
// throws makes the envelope bounce rather than stop the round.
|
|
8044
|
+
return { text, resolved: { error: `the destination couldn't be resolved: ${err instanceof Error ? err.message : String(err)}` } };
|
|
8045
|
+
}
|
|
8046
|
+
});
|
|
8047
|
+
}
|
|
8048
|
+
async deliverAdmittedProse(agent, envelopes, round) {
|
|
8049
|
+
// Multi-envelope: a single prose block may address SEVERAL destinations,
|
|
8050
|
+
// each envelope routed independently (sticky/bounce as usual).
|
|
8051
|
+
for (const envelope of envelopes) {
|
|
8052
|
+
await this.deliverProseEnvelope(agent, envelope, round);
|
|
7397
8053
|
}
|
|
7398
|
-
|
|
7399
|
-
|
|
8054
|
+
}
|
|
8055
|
+
/**
|
|
8056
|
+
* Where a delivered round leaves its turn's sticky destination, as the
|
|
8057
|
+
* explicit gateway sets it once the round's envelopes run: each `>>`
|
|
8058
|
+
* envelope whose target resolved becomes the destination. One that didn't
|
|
8059
|
+
* resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text.
|
|
8060
|
+
*/
|
|
8061
|
+
roundDestination(current, envelopes) {
|
|
8062
|
+
let destination = current;
|
|
7400
8063
|
for (const envelope of envelopes) {
|
|
7401
|
-
if (
|
|
7402
|
-
|
|
7403
|
-
await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
|
|
8064
|
+
if (envelope.resolved && 'channelId' in envelope.resolved)
|
|
8065
|
+
destination = envelope.resolved.channelId;
|
|
7404
8066
|
}
|
|
8067
|
+
return destination;
|
|
7405
8068
|
}
|
|
7406
8069
|
/** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
|
|
7407
|
-
async deliverHybridProse(agent, rawText, locus, allowLocus) {
|
|
8070
|
+
async deliverHybridProse(agent, rawText, locus, allowLocus, turnRouting) {
|
|
8071
|
+
const routing = turnRouting;
|
|
7408
8072
|
const lines = rawText.split('\n');
|
|
7409
8073
|
const envelopes = [];
|
|
7410
8074
|
let current = [];
|
|
@@ -7424,18 +8088,18 @@ export class AgentFramework {
|
|
|
7424
8088
|
const attempted = normalized.startsWith('>>>');
|
|
7425
8089
|
const parsed = parseHybridProsePrefix(normalized);
|
|
7426
8090
|
if (parsed.continueTurn)
|
|
7427
|
-
|
|
8091
|
+
routing.logical.continuation = true;
|
|
7428
8092
|
if (parsed.kind === 'private') {
|
|
7429
|
-
|
|
7430
|
-
|
|
8093
|
+
delete routing.pin;
|
|
8094
|
+
routing.hybridHold = true;
|
|
7431
8095
|
console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
7432
8096
|
continue;
|
|
7433
8097
|
}
|
|
7434
8098
|
if (parsed.kind === 'target') {
|
|
7435
8099
|
const resolved = this.channelRegistry.resolveProseTarget(parsed.target);
|
|
7436
8100
|
if ('error' in resolved) {
|
|
7437
|
-
|
|
7438
|
-
|
|
8101
|
+
delete routing.pin;
|
|
8102
|
+
routing.hybridHold = true;
|
|
7439
8103
|
this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
|
|
7440
8104
|
continue;
|
|
7441
8105
|
}
|
|
@@ -7443,63 +8107,67 @@ export class AgentFramework {
|
|
|
7443
8107
|
const usedClipboard = body.includes('{{unsent}}');
|
|
7444
8108
|
if (usedClipboard)
|
|
7445
8109
|
body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
|
|
7446
|
-
|
|
7447
|
-
|
|
8110
|
+
routing.pin = resolved.channelId;
|
|
8111
|
+
routing.hybridHold = false;
|
|
7448
8112
|
if (!body.trim()) {
|
|
7449
8113
|
console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
|
|
7450
8114
|
continue;
|
|
7451
8115
|
}
|
|
7452
8116
|
try {
|
|
7453
8117
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, body, resolved.channelId);
|
|
7454
|
-
this.recordProseDelivery(
|
|
8118
|
+
this.recordProseDelivery(routing, outcome);
|
|
7455
8119
|
if (outcome?.delivered) {
|
|
7456
8120
|
this.proseBounceStreaks.delete(agent.name);
|
|
7457
8121
|
if (usedClipboard)
|
|
7458
8122
|
this.proseClipboards.delete(agent.name);
|
|
7459
8123
|
}
|
|
7460
8124
|
else {
|
|
7461
|
-
|
|
7462
|
-
|
|
8125
|
+
delete routing.pin;
|
|
8126
|
+
routing.hybridHold = true;
|
|
7463
8127
|
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
|
|
7464
8128
|
}
|
|
7465
8129
|
}
|
|
7466
8130
|
catch (err) {
|
|
7467
|
-
|
|
7468
|
-
|
|
8131
|
+
delete routing.pin;
|
|
8132
|
+
routing.hybridHold = true;
|
|
7469
8133
|
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
|
|
7470
8134
|
}
|
|
7471
8135
|
continue;
|
|
7472
8136
|
}
|
|
7473
8137
|
if (attempted) {
|
|
7474
|
-
|
|
7475
|
-
|
|
8138
|
+
delete routing.pin;
|
|
8139
|
+
routing.hybridHold = true;
|
|
7476
8140
|
this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
|
|
7477
8141
|
continue;
|
|
7478
8142
|
}
|
|
7479
|
-
if (
|
|
7480
|
-
this.recordProseSuppression(
|
|
8143
|
+
if (routing.hybridHold) {
|
|
8144
|
+
this.recordProseSuppression(routing, 1);
|
|
7481
8145
|
continue;
|
|
7482
8146
|
}
|
|
7483
|
-
const sticky =
|
|
8147
|
+
const sticky = routing.pin;
|
|
7484
8148
|
if (!allowLocus && !sticky) {
|
|
7485
|
-
this.recordProseSuppression(
|
|
8149
|
+
this.recordProseSuppression(routing, 1);
|
|
7486
8150
|
continue;
|
|
7487
8151
|
}
|
|
7488
8152
|
const target = sticky ?? locus;
|
|
7489
8153
|
try {
|
|
7490
8154
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
|
|
7491
|
-
this.recordProseDelivery(
|
|
8155
|
+
this.recordProseDelivery(routing, outcome);
|
|
7492
8156
|
}
|
|
7493
8157
|
catch (err) {
|
|
7494
8158
|
console.error('hybrid locus delivery failed:', err);
|
|
7495
8159
|
}
|
|
7496
8160
|
}
|
|
7497
8161
|
}
|
|
7498
|
-
async deliverProseEnvelope(agent,
|
|
8162
|
+
async deliverProseEnvelope(agent, envelope, round) {
|
|
7499
8163
|
const name = agent.name;
|
|
8164
|
+
const routing = round.routing;
|
|
8165
|
+
const rawText = envelope.text;
|
|
7500
8166
|
const parsed = parseProsePrefix(rawText);
|
|
7501
|
-
|
|
7502
|
-
|
|
8167
|
+
// A private round (one that called skip_reply) asks for no publication
|
|
8168
|
+
// or continuation, even when delivery runs after that turn has ended.
|
|
8169
|
+
if (parsed.continueTurn && !round.keepPrivate)
|
|
8170
|
+
routing.logical.continuation = true;
|
|
7503
8171
|
if (parsed.kind === 'private') {
|
|
7504
8172
|
// The text already lives in the assistant message (window/chronicle);
|
|
7505
8173
|
// "delivery" is deliberately a no-op. Consumes the retained text if the
|
|
@@ -7509,21 +8177,34 @@ export class AgentFramework {
|
|
|
7509
8177
|
console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
7510
8178
|
return;
|
|
7511
8179
|
}
|
|
8180
|
+
if (round.keepPrivate) {
|
|
8181
|
+
// skip_reply's promise: "Any plain text you wrote this turn stays
|
|
8182
|
+
// private and is NOT posted". Every envelope of the round stays in the
|
|
8183
|
+
// context, as a >>skip_reply envelope does: it isn't sent, doesn't
|
|
8184
|
+
// bounce to the clipboard and doesn't move the destination.
|
|
8185
|
+
console.error(`[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` +
|
|
8186
|
+
`${parsed.body.length} chars kept in context (skip_reply round)`);
|
|
8187
|
+
return;
|
|
8188
|
+
}
|
|
7512
8189
|
let targetChannel;
|
|
7513
8190
|
let body;
|
|
7514
8191
|
if (parsed.kind === 'target') {
|
|
7515
|
-
|
|
8192
|
+
// Resolved once, when the round was admitted (admitProse), never again
|
|
8193
|
+
// here after the wait: a `>>` envelope without its resolution is a bug.
|
|
8194
|
+
const res = envelope.resolved;
|
|
8195
|
+
if (!res)
|
|
8196
|
+
throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`);
|
|
7516
8197
|
if ('error' in res) {
|
|
7517
8198
|
this.bounceProse(agent, parsed.body, res.error, res.candidates);
|
|
7518
8199
|
return;
|
|
7519
8200
|
}
|
|
7520
8201
|
targetChannel = res.channelId;
|
|
7521
8202
|
// Sticky within the turn: later unprefixed segments follow this target.
|
|
7522
|
-
|
|
8203
|
+
routing.pin = targetChannel;
|
|
7523
8204
|
body = parsed.body;
|
|
7524
8205
|
}
|
|
7525
8206
|
else {
|
|
7526
|
-
const sticky =
|
|
8207
|
+
const sticky = routing.pin;
|
|
7527
8208
|
if (!sticky) {
|
|
7528
8209
|
this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
|
|
7529
8210
|
return;
|
|
@@ -7737,8 +8418,8 @@ export class AgentFramework {
|
|
|
7737
8418
|
* driveStream (whose finally then owns clearing it).
|
|
7738
8419
|
*/
|
|
7739
8420
|
async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
|
|
7740
|
-
const
|
|
7741
|
-
|
|
8421
|
+
const lifecycle = this.lifecycleGeneration;
|
|
8422
|
+
const continuingTurn = resumesLogicalTurn(trigger?.reason);
|
|
7742
8423
|
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
7743
8424
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
7744
8425
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
@@ -7779,14 +8460,47 @@ export class AgentFramework {
|
|
|
7779
8460
|
// boundary — after the deferred flush (same window: turn alive, compile
|
|
7780
8461
|
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
7781
8462
|
// Not on a context-budget restart: that continues the same logical turn.
|
|
8463
|
+
let assembly;
|
|
7782
8464
|
if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
|
|
7783
8465
|
try {
|
|
7784
|
-
await this.pushCoalescer.assemble(agent.name);
|
|
8466
|
+
assembly = await this.pushCoalescer.assemble(agent.name);
|
|
7785
8467
|
}
|
|
7786
8468
|
catch (err) {
|
|
7787
8469
|
console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
|
|
7788
8470
|
}
|
|
7789
8471
|
}
|
|
8472
|
+
// A turn whose only causes were deferred batches, all of which rendered
|
|
8473
|
+
// nothing (empty or blank render, §5.2; cancelled; revoked), has nothing
|
|
8474
|
+
// new to show: running it would wake the model to `[Continue]` or an
|
|
8475
|
+
// older message, an uncaused wake. Stop before the checkpoint, locus,
|
|
8476
|
+
// typing and compile. Any batch this assembly materialized keeps the
|
|
8477
|
+
// turn, including one that was not among its causes (a batch whose wake
|
|
8478
|
+
// the freeze withdrew, or one that never qualified for a wake). A cause
|
|
8479
|
+
// this assembly did not settle (rendered elsewhere, not yet frozen) keeps
|
|
8480
|
+
// the turn, as before.
|
|
8481
|
+
const batchSubjects = trigger?.coalescingBatchSubjects;
|
|
8482
|
+
if (assembly && batchSubjects?.length && assembly.materialized.size === 0
|
|
8483
|
+
&& batchSubjects.every((s) => assembly.settled.has(s))) {
|
|
8484
|
+
console.error(`[coalescing] ${agent.name}: deferred render produced nothing; turn not started (no wake cause left)`);
|
|
8485
|
+
this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: agent.name, subjects: batchSubjects });
|
|
8486
|
+
// Release the turn and land anything deferred while assembly awaited
|
|
8487
|
+
// the render, as a torn-down turn would.
|
|
8488
|
+
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
8489
|
+
this.activeTurnTokens.delete(agent.name);
|
|
8490
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
8491
|
+
}
|
|
8492
|
+
if (!this.activeTurnTokens.has(agent.name)
|
|
8493
|
+
&& this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
8494
|
+
for (const msg of this.drainDeferredFor(agent.name)) {
|
|
8495
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, {
|
|
8496
|
+
deferredWriteId: msg.id,
|
|
8497
|
+
...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
|
|
8498
|
+
});
|
|
8499
|
+
}
|
|
8500
|
+
this.ackDeferredWrites();
|
|
8501
|
+
}
|
|
8502
|
+
return false;
|
|
8503
|
+
}
|
|
7790
8504
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
7791
8505
|
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
7792
8506
|
this.recordTurnCheckpoint(agent.name);
|
|
@@ -7841,21 +8555,32 @@ export class AgentFramework {
|
|
|
7841
8555
|
// A framework retry is a new physical stream in the same logical turn.
|
|
7842
8556
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
7843
8557
|
}
|
|
7844
|
-
// Fresh turn: forget the previous turn's explicit-send engagements
|
|
7845
|
-
//
|
|
7846
|
-
// the same logical turn and keeps them).
|
|
8558
|
+
// Fresh turn: forget the previous turn's explicit-send engagements.
|
|
8559
|
+
// A restart continues the same logical turn and keeps them.
|
|
7847
8560
|
this.turnEngagedChannels.delete(agent.name);
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
8561
|
+
// A new routing state for the new turn, in every mode: the sticky
|
|
8562
|
+
// destination, the turn's destination, hybrid's hold and the receipt
|
|
8563
|
+
// tallies start empty. The previous turn's object isn't cleared: prose
|
|
8564
|
+
// it left queued still reads and writes its own.
|
|
8565
|
+
const fresh = freshTurnRouting();
|
|
8566
|
+
// A start while the agent's logical turn is open continues it: it
|
|
8567
|
+
// shares the earlier stream's ` !` and receipt tallies, which a
|
|
8568
|
+
// delivery still queued there can write after this stream begins
|
|
8569
|
+
// (openLogical). Otherwise this start opens one. A silent start does
|
|
8570
|
+
// neither: it routes no prose, so it keeps a logical turn of its own,
|
|
8571
|
+
// and an open turn waits for the next start that routes prose.
|
|
8572
|
+
if (!trigger?.suppressProse) {
|
|
8573
|
+
const open = this.openLogical.get(agent.name);
|
|
8574
|
+
if (open)
|
|
8575
|
+
fresh.logical = open;
|
|
8576
|
+
else
|
|
8577
|
+
this.openLogical.set(agent.name, fresh.logical);
|
|
8578
|
+
}
|
|
8579
|
+
this.turnRouting.set(agent.name, fresh);
|
|
7853
8580
|
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
7854
8581
|
// Explicit and disabled prose routing have no inferred locus.
|
|
7855
8582
|
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
7856
8583
|
// prose target at all. Neither mode freezes or announces a locus.
|
|
7857
|
-
this.proseTargetPins.delete(agent.name);
|
|
7858
|
-
this.proseContinuations.delete(agent.name);
|
|
7859
8584
|
this.midTurnInputSignals.delete(agent.name);
|
|
7860
8585
|
this.turnLocusPins.delete(agent.name);
|
|
7861
8586
|
}
|
|
@@ -7875,9 +8600,17 @@ export class AgentFramework {
|
|
|
7875
8600
|
// opened/pinned a channel mid-turn.
|
|
7876
8601
|
if (trigger?.suppressProse) {
|
|
7877
8602
|
this.turnLocusPins.delete(agent.name);
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
8603
|
+
// Silent: no routing state. A continuing silent start keeps its turn's
|
|
8604
|
+
// logical turn; a fresh one keeps the one of its own it was given.
|
|
8605
|
+
const silent = freshTurnRouting();
|
|
8606
|
+
const logical = this.turnRouting.get(agent.name)?.logical;
|
|
8607
|
+
if (logical)
|
|
8608
|
+
silent.logical = logical;
|
|
8609
|
+
this.turnRouting.set(agent.name, silent);
|
|
8610
|
+
}
|
|
8611
|
+
// This start's own logical turn: a failure before its stream exists
|
|
8612
|
+
// settles it, and no other start's.
|
|
8613
|
+
const startLogical = this.turnRouting.get(agent.name)?.logical;
|
|
7881
8614
|
this.touchEphemeralRun(agent.name, true);
|
|
7882
8615
|
this.emitTrace({
|
|
7883
8616
|
type: 'inference:started',
|
|
@@ -7909,7 +8642,11 @@ export class AgentFramework {
|
|
|
7909
8642
|
this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
7910
8643
|
try {
|
|
7911
8644
|
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
7912
|
-
|
|
8645
|
+
// Capture both surfaces together before context hooks can refresh tools.
|
|
8646
|
+
const presentation = this.inspectToolPresentation(agent.name, requestSnapshot);
|
|
8647
|
+
const compressionTools = presentation?.available
|
|
8648
|
+
?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot));
|
|
8649
|
+
const tools = presentation?.advertised ?? compressionTools;
|
|
7913
8650
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
7914
8651
|
// Both produce ContextInjection[] that get merged before inference.
|
|
7915
8652
|
let injections;
|
|
@@ -7948,9 +8685,10 @@ export class AgentFramework {
|
|
|
7948
8685
|
};
|
|
7949
8686
|
injections = injections ? [...injections, silentInjection] : [silentInjection];
|
|
7950
8687
|
}
|
|
7951
|
-
// An ephemeral watchdog may dispose this Agent while hooks/compile await
|
|
7952
|
-
//
|
|
7953
|
-
|
|
8688
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await,
|
|
8689
|
+
// and stop() may close provider admission. Do not create a provider
|
|
8690
|
+
// stream after its generation lost ownership, or once admission closed.
|
|
8691
|
+
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
|
|
7954
8692
|
this.channelRegistry?.stopTyping();
|
|
7955
8693
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
7956
8694
|
return false;
|
|
@@ -7965,10 +8703,27 @@ export class AgentFramework {
|
|
|
7965
8703
|
injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
|
|
7966
8704
|
}
|
|
7967
8705
|
}
|
|
7968
|
-
const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections
|
|
7969
|
-
|
|
8706
|
+
const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools, {
|
|
8707
|
+
silentHeartbeat: trigger?.silentHeartbeat,
|
|
8708
|
+
});
|
|
8709
|
+
// The compile can wait for annotations (each batch has one spill
|
|
8710
|
+
// deadline), so check ownership, admission, and the lifecycle again.
|
|
8711
|
+
// A stop followed by start must not revive this earlier turn.
|
|
8712
|
+
// Membrane's yielding stream starts its request when it is first
|
|
8713
|
+
// iterated, so cancelling it here, before driveStream, sends nothing.
|
|
8714
|
+
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
|
|
8715
|
+
console.error(`[turn] ${agent.name}: a turn compiled while ${this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
|
|
8716
|
+
: this.providerAdmissionClosed ? 'the framework was stopping'
|
|
8717
|
+
: 'the framework was stopped and started again'} is dropped before it sends anything`);
|
|
7970
8718
|
stream.cancel();
|
|
7971
8719
|
agent.cancelStream();
|
|
8720
|
+
if (this.agents.get(agent.name) === agent) {
|
|
8721
|
+
// The compile took the agent's stream id, so a frame that started
|
|
8722
|
+
// this one (a guard or budget restart) no longer owns the entries
|
|
8723
|
+
// it left, and its teardown leaves them to this frame.
|
|
8724
|
+
this.activeStreams.delete(agent.name);
|
|
8725
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
8726
|
+
}
|
|
7972
8727
|
this.channelRegistry?.stopTyping();
|
|
7973
8728
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
7974
8729
|
return false;
|
|
@@ -8019,7 +8774,7 @@ export class AgentFramework {
|
|
|
8019
8774
|
stopReason: 'exhausted',
|
|
8020
8775
|
speech: '',
|
|
8021
8776
|
error: err.message,
|
|
8022
|
-
});
|
|
8777
|
+
}, startLogical);
|
|
8023
8778
|
this.emitTrace({
|
|
8024
8779
|
type: 'inference:exhausted',
|
|
8025
8780
|
agentName: agent.name,
|
|
@@ -8071,6 +8826,9 @@ export class AgentFramework {
|
|
|
8071
8826
|
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
8072
8827
|
// means no successor turn can have replaced it while we compiled.
|
|
8073
8828
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
8829
|
+
// The turn-alive guard also keeps this turn's routing current at entry.
|
|
8830
|
+
// Retain it for queued deliveries and receipts after a tool ends the turn.
|
|
8831
|
+
const streamRouting = this.routingOf(agent.name);
|
|
8074
8832
|
let hadToolCalls = false;
|
|
8075
8833
|
let generationLost = false;
|
|
8076
8834
|
// ---- Present-while-acting turn state ---------------------------------
|
|
@@ -8089,14 +8847,27 @@ export class AgentFramework {
|
|
|
8089
8847
|
// awaits the chain before routing trailing prose, so in-channel ordering
|
|
8090
8848
|
// is preserved end-to-end. Each link catches its own error: one failed
|
|
8091
8849
|
// post must not silence the rest of the turn.
|
|
8092
|
-
|
|
8850
|
+
//
|
|
8851
|
+
// Every stream queues its deliveries behind the agent's latest link, so
|
|
8852
|
+
// the agent's posts go out in the order they were written, across a
|
|
8853
|
+
// turn's streams (a budget restart, a tool-result-guard retry) and across
|
|
8854
|
+
// turns. A turn that a tool ends (skip_reply) settles before its queued
|
|
8855
|
+
// prose has posted; that prose keeps its own turn's routing state
|
|
8856
|
+
// (turnRouting), and the next turn's posts wait behind it.
|
|
8857
|
+
const resumesTurn = resumesLogicalTurn(trigger?.reason);
|
|
8858
|
+
let turnSpeechChain = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
|
|
8859
|
+
this.proseSpeechChains.set(agent.name, turnSpeechChain);
|
|
8860
|
+
const chainSpeech = (link, failure) => {
|
|
8861
|
+
turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err));
|
|
8862
|
+
// A stream another has replaced doesn't move the turn's latest link.
|
|
8863
|
+
if (agent.streamId === myStreamId)
|
|
8864
|
+
this.proseSpeechChains.set(agent.name, turnSpeechChain);
|
|
8865
|
+
};
|
|
8093
8866
|
const enqueueSpeech = (text, locus) => {
|
|
8094
|
-
|
|
8095
|
-
.then(async () => {
|
|
8867
|
+
chainSpeech(async () => {
|
|
8096
8868
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
8097
|
-
this.recordProseDelivery(
|
|
8098
|
-
})
|
|
8099
|
-
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
8869
|
+
this.recordProseDelivery(streamRouting, outcome);
|
|
8870
|
+
}, 'mid-turn speech routing failed:');
|
|
8100
8871
|
};
|
|
8101
8872
|
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
8102
8873
|
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
@@ -8169,7 +8940,16 @@ export class AgentFramework {
|
|
|
8169
8940
|
!agent.toolWrapperProseGuard
|
|
8170
8941
|
? new ProseStreamRouter({
|
|
8171
8942
|
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
8172
|
-
|
|
8943
|
+
// Explicit text has no destination until a `>>` prefix names one,
|
|
8944
|
+
// so a fresh turn's stream starts suppressed. One that resumes the
|
|
8945
|
+
// turn (a tool-result-guard retry, a budget restart) starts at the
|
|
8946
|
+
// destination the turn's delivered rounds have set: a private
|
|
8947
|
+
// round's prefixes don't count, and the gateway's queued
|
|
8948
|
+
// deliveries needn't have run. The trigger channel is only where
|
|
8949
|
+
// explicit mode's typing indicator shows.
|
|
8950
|
+
initialTarget: turnProseRouting === 'explicit'
|
|
8951
|
+
? (resumesTurn ? streamRouting.destination : null)
|
|
8952
|
+
: typingChannel,
|
|
8173
8953
|
resolve: (spec) => {
|
|
8174
8954
|
const r = this.channelRegistry.resolveProseTarget(spec);
|
|
8175
8955
|
return 'channelId' in r ? r.channelId : null;
|
|
@@ -8188,6 +8968,8 @@ export class AgentFramework {
|
|
|
8188
8968
|
// other trace consumers voice it). Hold both; release in order on a clean
|
|
8189
8969
|
// round (tool-calls / non-guard completion), discard on a guard refusal.
|
|
8190
8970
|
let guardHeld = [];
|
|
8971
|
+
// The text block the stream's last text came from, within this round.
|
|
8972
|
+
let lastTextBlock;
|
|
8191
8973
|
const releaseGuardHeld = () => {
|
|
8192
8974
|
const held = guardHeld;
|
|
8193
8975
|
guardHeld = [];
|
|
@@ -8231,7 +9013,18 @@ export class AgentFramework {
|
|
|
8231
9013
|
// turn's prose is bound for, without re-deriving routing.
|
|
8232
9014
|
channelId: typingChannel ?? undefined,
|
|
8233
9015
|
};
|
|
8234
|
-
|
|
9016
|
+
// Delivery joins a round's text blocks with a newline
|
|
9017
|
+
// (splitProseSegments), so a later block starts a new line for
|
|
9018
|
+
// the stream too.
|
|
9019
|
+
let text = event.meta.type === 'text' ? event.content : undefined;
|
|
9020
|
+
if (text !== undefined) {
|
|
9021
|
+
const block = event.meta.blockIndex;
|
|
9022
|
+
if (block !== undefined) {
|
|
9023
|
+
if (lastTextBlock !== undefined && block !== lastTextBlock)
|
|
9024
|
+
text = '\n' + text;
|
|
9025
|
+
lastTextBlock = block;
|
|
9026
|
+
}
|
|
9027
|
+
}
|
|
8235
9028
|
if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
|
|
8236
9029
|
guardHeld.push({ trace, text });
|
|
8237
9030
|
}
|
|
@@ -8263,6 +9056,12 @@ export class AgentFramework {
|
|
|
8263
9056
|
outgoingIndex = 0;
|
|
8264
9057
|
discardGuardHeld();
|
|
8265
9058
|
proseStream?.reset();
|
|
9059
|
+
lastTextBlock = undefined;
|
|
9060
|
+
// Explicit mode's retried attempt starts where the turn's
|
|
9061
|
+
// delivered rounds have set the destination, not at the stream's
|
|
9062
|
+
// seed: rounds this stream delivered before the refusal stand.
|
|
9063
|
+
if (turnProseRouting === 'explicit')
|
|
9064
|
+
proseStream?.retarget(streamRouting.destination);
|
|
8266
9065
|
this.emitTrace({
|
|
8267
9066
|
type: 'inference:tokens',
|
|
8268
9067
|
agentName: agent.name,
|
|
@@ -8290,6 +9089,11 @@ export class AgentFramework {
|
|
|
8290
9089
|
// preceding results before storing/dispatching this new round.
|
|
8291
9090
|
agent.toolResultGuard.accept();
|
|
8292
9091
|
releaseGuardHeld();
|
|
9092
|
+
// The round's text ends here: delivery parses each round's text
|
|
9093
|
+
// on its own, so the next round's text starts a new line.
|
|
9094
|
+
if (proseStream)
|
|
9095
|
+
emitOutgoing(proseStream.endSegment());
|
|
9096
|
+
lastTextBlock = undefined;
|
|
8293
9097
|
adoptInjectedRound();
|
|
8294
9098
|
hadToolCalls = true;
|
|
8295
9099
|
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
@@ -8388,39 +9192,58 @@ export class AgentFramework {
|
|
|
8388
9192
|
if (roundSegments.length > 0) {
|
|
8389
9193
|
if (turnProseRouting === 'disabled') {
|
|
8390
9194
|
console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
|
|
8391
|
-
this.recordProseSuppression(
|
|
9195
|
+
this.recordProseSuppression(streamRouting, roundSegments.length);
|
|
8392
9196
|
}
|
|
8393
9197
|
else if (turnProseRouting === 'hybrid') {
|
|
8394
9198
|
if (turnSilenced) {
|
|
8395
|
-
this.recordProseSuppression(
|
|
9199
|
+
this.recordProseSuppression(streamRouting, roundSegments.length);
|
|
8396
9200
|
}
|
|
8397
9201
|
else if (!hasSameRoundPrivateThink) {
|
|
8398
9202
|
const locus = resolveTurnLocus();
|
|
9203
|
+
// Queued under this turn's routing, whenever it posts.
|
|
9204
|
+
const routing = streamRouting;
|
|
8399
9205
|
for (const seg of roundSegments) {
|
|
8400
|
-
|
|
8401
|
-
.then(() => this.deliverHybridProse(agent, seg, locus, true))
|
|
8402
|
-
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
9206
|
+
chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true, routing), 'mid-turn hybrid prose delivery failed:');
|
|
8403
9207
|
}
|
|
8404
9208
|
}
|
|
8405
9209
|
}
|
|
8406
9210
|
else if (turnProseRouting === 'explicit') {
|
|
8407
|
-
// Explicit mode:
|
|
8408
|
-
//
|
|
8409
|
-
//
|
|
9211
|
+
// Explicit mode: the segments go through the prose
|
|
9212
|
+
// gateway, unless a private think in the round holds them
|
|
9213
|
+
// back. Send silencing does not apply: unprefixed prose
|
|
9214
|
+
// bounces, and prefixed prose is deliberate. skip_reply
|
|
9215
|
+
// does: its round's envelopes all stay in the context
|
|
9216
|
+
// (deliverProseEnvelope's keepPrivate), as the tool
|
|
9217
|
+
// promises, and skip_reply then ends the turn at this
|
|
9218
|
+
// round's tool results.
|
|
8410
9219
|
if (!hasSameRoundPrivateThink) {
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
|
|
9220
|
+
const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply');
|
|
9221
|
+
// The round is admitted now: its targets are resolved
|
|
9222
|
+
// once, for the turn's destination and for its queued
|
|
9223
|
+
// delivery alike.
|
|
9224
|
+
const admitted = roundSegments.map((seg) => this.admitProse(seg));
|
|
9225
|
+
// Queued under this turn's routing, whenever it posts.
|
|
9226
|
+
const routing = streamRouting;
|
|
9227
|
+
if (!keepPrivate) {
|
|
9228
|
+
routing.destination = this.roundDestination(routing.destination, admitted.flat());
|
|
9229
|
+
}
|
|
9230
|
+
console.error(`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`);
|
|
9231
|
+
for (const envelopes of admitted) {
|
|
9232
|
+
chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate, routing }), 'mid-turn prose delivery failed:');
|
|
8416
9233
|
}
|
|
8417
9234
|
}
|
|
9235
|
+
// The stream's next round starts where delivery sends
|
|
9236
|
+
// unprefixed text: the turn's destination. The stream
|
|
9237
|
+
// followed this round's prefixes as it was written, but a
|
|
9238
|
+
// private round's, a >>skip_reply envelope's or a target
|
|
9239
|
+
// that didn't resolve leave the destination as it was.
|
|
9240
|
+
proseStream?.retarget(streamRouting.destination);
|
|
8418
9241
|
}
|
|
8419
9242
|
else if (turnSilenced) {
|
|
8420
9243
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
8421
9244
|
// Visible in the turn-end receipt — silencing must never
|
|
8422
9245
|
// be a silent black hole (n=8: the flying-scene reply).
|
|
8423
|
-
this.recordProseSuppression(
|
|
9246
|
+
this.recordProseSuppression(streamRouting, roundSegments.length);
|
|
8424
9247
|
}
|
|
8425
9248
|
else if (hasSameRoundPrivateThink) {
|
|
8426
9249
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
|
|
@@ -8458,6 +9281,8 @@ export class AgentFramework {
|
|
|
8458
9281
|
// persisted as assistant speech, including a terminal refusal
|
|
8459
9282
|
// after the single recovery retry.
|
|
8460
9283
|
proseStream?.reset();
|
|
9284
|
+
if (turnProseRouting === 'explicit')
|
|
9285
|
+
proseStream?.retarget(streamRouting.destination);
|
|
8461
9286
|
if (withheld) {
|
|
8462
9287
|
const usage = response.details?.usage ?? response.usage;
|
|
8463
9288
|
const tokenUsage = usage ? {
|
|
@@ -8539,7 +9364,7 @@ export class AgentFramework {
|
|
|
8539
9364
|
// Flush pending assistant blocks
|
|
8540
9365
|
const pending = this.pendingAssistantBlocks.get(agent.name);
|
|
8541
9366
|
if (pending) {
|
|
8542
|
-
agent.addAssistantResponse(pending);
|
|
9367
|
+
agent.addAssistantResponse(pending, this.silentTurnRowMetadata(agent.name));
|
|
8543
9368
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
8544
9369
|
}
|
|
8545
9370
|
// Store tool results — same bounded spill policy as the
|
|
@@ -8549,7 +9374,7 @@ export class AgentFramework {
|
|
|
8549
9374
|
if (readyState.status === 'ready') {
|
|
8550
9375
|
const cap = this.resolveToolResultInlineCap(agent).cap;
|
|
8551
9376
|
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
|
|
8552
|
-
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
|
|
9377
|
+
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults, this.silentTurnRowMetadata(agent.name));
|
|
8553
9378
|
// The response is already complete: this batch is never
|
|
8554
9379
|
// submitted in this turn, so it cannot be refused. Admit it.
|
|
8555
9380
|
agent.toolResultGuard.settleTurnEnded();
|
|
@@ -8585,16 +9410,17 @@ export class AgentFramework {
|
|
|
8585
9410
|
system: true,
|
|
8586
9411
|
kind: 'tool-wrapper-prose-contained',
|
|
8587
9412
|
toolName: guardedWrapperTool,
|
|
9413
|
+
...this.silentTurnRowMetadata(agent.name),
|
|
8588
9414
|
});
|
|
8589
9415
|
console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
|
|
8590
9416
|
}
|
|
8591
9417
|
else if (lastToolIdx >= 0) {
|
|
8592
9418
|
if (terminalContent.length > 0) {
|
|
8593
|
-
agent.addAssistantResponse(terminalContent);
|
|
9419
|
+
agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
|
|
8594
9420
|
}
|
|
8595
9421
|
}
|
|
8596
9422
|
else {
|
|
8597
|
-
agent.addAssistantResponse(terminalContent);
|
|
9423
|
+
agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
|
|
8598
9424
|
}
|
|
8599
9425
|
// Bind the cooldown receipt to this fresh, successful request —
|
|
8600
9426
|
// never to the frozen request that received the 429.
|
|
@@ -8616,6 +9442,19 @@ export class AgentFramework {
|
|
|
8616
9442
|
: response.content.filter(isTextBlock);
|
|
8617
9443
|
const speechContent = hadToolCalls ? [] : allText;
|
|
8618
9444
|
const thoughts = hadToolCalls ? allText : [];
|
|
9445
|
+
// Explicit text is admitted as its round ends, before anything
|
|
9446
|
+
// below waits (a module's speech callback, the turn's earlier
|
|
9447
|
+
// deliveries): its targets resolve now, as a live round's do.
|
|
9448
|
+
const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim();
|
|
9449
|
+
const finalSegments = hadToolCalls && allText.length > 0
|
|
9450
|
+
? splitProseSegments(liveProseRouting ? terminalContent : response.content)
|
|
9451
|
+
: [];
|
|
9452
|
+
const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry;
|
|
9453
|
+
// The final text is delivered under this turn's routing, as its
|
|
9454
|
+
// queued rounds are.
|
|
9455
|
+
const finalRouting = streamRouting;
|
|
9456
|
+
const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null;
|
|
9457
|
+
const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : [];
|
|
8619
9458
|
const du = response.details?.usage;
|
|
8620
9459
|
const tokenUsage = du
|
|
8621
9460
|
? {
|
|
@@ -8649,7 +9488,7 @@ export class AgentFramework {
|
|
|
8649
9488
|
.filter((block) => block.type === 'text')
|
|
8650
9489
|
.map((block) => block.text)
|
|
8651
9490
|
.join('\n'),
|
|
8652
|
-
});
|
|
9491
|
+
}, streamRouting.logical);
|
|
8653
9492
|
this.emitTrace({
|
|
8654
9493
|
type: 'inference:completed',
|
|
8655
9494
|
agentName: agent.name,
|
|
@@ -8799,14 +9638,16 @@ export class AgentFramework {
|
|
|
8799
9638
|
// they are never routed here — which is precisely how the `think`
|
|
8800
9639
|
// tool (and any explicit send tool) yields a silent turn.
|
|
8801
9640
|
if (speechContent.length > 0 && this.channelRegistry) {
|
|
8802
|
-
const speechText =
|
|
8803
|
-
.map((b) => b.text)
|
|
8804
|
-
.join('\n')
|
|
8805
|
-
.trim();
|
|
9641
|
+
const speechText = finalSpeechText;
|
|
8806
9642
|
if (speechText) {
|
|
9643
|
+
// After the agent's earlier deliveries: every stream's chain
|
|
9644
|
+
// starts from the agent's latest link, so this waits for the
|
|
9645
|
+
// turn's earlier rounds and for an earlier turn's posts still
|
|
9646
|
+
// queued.
|
|
9647
|
+
await turnSpeechChain;
|
|
8807
9648
|
if (turnProseRouting === 'disabled') {
|
|
8808
9649
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
8809
|
-
this.recordProseSuppression(
|
|
9650
|
+
this.recordProseSuppression(streamRouting, 1);
|
|
8810
9651
|
}
|
|
8811
9652
|
else if (turnSilenced && turnProseRouting !== 'explicit') {
|
|
8812
9653
|
// Only reachable on a guard recovery that carried the
|
|
@@ -8814,13 +9655,13 @@ export class AgentFramework {
|
|
|
8814
9655
|
// (a fresh text-only turn starts unsilenced). Explicit
|
|
8815
9656
|
// mode is exempt, as mid-turn.
|
|
8816
9657
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
|
|
8817
|
-
this.recordProseSuppression(
|
|
9658
|
+
this.recordProseSuppression(streamRouting, 1);
|
|
8818
9659
|
}
|
|
8819
9660
|
else if (turnProseRouting === 'hybrid') {
|
|
8820
9661
|
const locus = resolveTurnLocus();
|
|
8821
9662
|
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
8822
9663
|
try {
|
|
8823
|
-
await this.deliverHybridProse(agent, speechText, locus, true);
|
|
9664
|
+
await this.deliverHybridProse(agent, speechText, locus, true, finalRouting);
|
|
8824
9665
|
}
|
|
8825
9666
|
catch (err) {
|
|
8826
9667
|
console.error('text-only hybrid prose delivery failed:', err);
|
|
@@ -8829,7 +9670,7 @@ export class AgentFramework {
|
|
|
8829
9670
|
else if (turnProseRouting === 'explicit') {
|
|
8830
9671
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
8831
9672
|
try {
|
|
8832
|
-
await this.
|
|
9673
|
+
await this.deliverAdmittedProse(agent, admittedSpeech, { routing: finalRouting });
|
|
8833
9674
|
}
|
|
8834
9675
|
catch (err) {
|
|
8835
9676
|
console.error('text-only prose delivery failed:', err);
|
|
@@ -8846,7 +9687,7 @@ export class AgentFramework {
|
|
|
8846
9687
|
console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
|
|
8847
9688
|
try {
|
|
8848
9689
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
8849
|
-
this.recordProseDelivery(
|
|
9690
|
+
this.recordProseDelivery(streamRouting, outcome);
|
|
8850
9691
|
}
|
|
8851
9692
|
catch (err) {
|
|
8852
9693
|
console.error('speech routing failed:', err);
|
|
@@ -8876,7 +9717,7 @@ export class AgentFramework {
|
|
|
8876
9717
|
const silenced = liveProseRouting
|
|
8877
9718
|
? turnSilenced
|
|
8878
9719
|
: turnSilenced || toolNames.some(isSilencingTool);
|
|
8879
|
-
const segments =
|
|
9720
|
+
const segments = finalSegments;
|
|
8880
9721
|
// Preserve in-channel ordering: everything enqueued live must
|
|
8881
9722
|
// land before the trailing prose. Awaited even when silenced —
|
|
8882
9723
|
// the chain may still be flushing earlier rounds' posts.
|
|
@@ -8884,18 +9725,18 @@ export class AgentFramework {
|
|
|
8884
9725
|
if (turnProseRouting === 'disabled') {
|
|
8885
9726
|
if (segments.length > 0) {
|
|
8886
9727
|
console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
|
|
8887
|
-
this.recordProseSuppression(
|
|
9728
|
+
this.recordProseSuppression(streamRouting, segments.length);
|
|
8888
9729
|
}
|
|
8889
9730
|
}
|
|
8890
9731
|
else if (turnProseRouting === 'hybrid') {
|
|
8891
9732
|
if (silenced && segments.length > 0) {
|
|
8892
|
-
this.recordProseSuppression(
|
|
9733
|
+
this.recordProseSuppression(streamRouting, segments.length);
|
|
8893
9734
|
}
|
|
8894
9735
|
else if (segments.length > 0) {
|
|
8895
9736
|
const locus = resolveTurnLocus();
|
|
8896
9737
|
for (const seg of segments) {
|
|
8897
9738
|
try {
|
|
8898
|
-
await this.deliverHybridProse(agent, seg, locus, true);
|
|
9739
|
+
await this.deliverHybridProse(agent, seg, locus, true, finalRouting);
|
|
8899
9740
|
}
|
|
8900
9741
|
catch (err) {
|
|
8901
9742
|
console.error('trailing hybrid prose delivery failed:', err);
|
|
@@ -8906,9 +9747,9 @@ export class AgentFramework {
|
|
|
8906
9747
|
else if (turnProseRouting === 'explicit') {
|
|
8907
9748
|
if (segments.length > 0) {
|
|
8908
9749
|
console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
|
|
8909
|
-
for (const
|
|
9750
|
+
for (const envelopes of admittedSegments) {
|
|
8910
9751
|
try {
|
|
8911
|
-
await this.
|
|
9752
|
+
await this.deliverAdmittedProse(agent, envelopes, { routing: finalRouting });
|
|
8912
9753
|
}
|
|
8913
9754
|
catch (err) {
|
|
8914
9755
|
console.error('trailing prose delivery failed:', err);
|
|
@@ -8920,7 +9761,7 @@ export class AgentFramework {
|
|
|
8920
9761
|
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
8921
9762
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
|
|
8922
9763
|
if (silenced && segments.length > 0) {
|
|
8923
|
-
this.recordProseSuppression(
|
|
9764
|
+
this.recordProseSuppression(streamRouting, segments.length);
|
|
8924
9765
|
}
|
|
8925
9766
|
}
|
|
8926
9767
|
else {
|
|
@@ -8937,7 +9778,7 @@ export class AgentFramework {
|
|
|
8937
9778
|
for (const seg of segments) {
|
|
8938
9779
|
try {
|
|
8939
9780
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
8940
|
-
this.recordProseDelivery(
|
|
9781
|
+
this.recordProseDelivery(streamRouting, outcome);
|
|
8941
9782
|
}
|
|
8942
9783
|
catch (err) {
|
|
8943
9784
|
console.error('speech routing failed:', err);
|
|
@@ -8949,27 +9790,20 @@ export class AgentFramework {
|
|
|
8949
9790
|
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
8950
9791
|
// doesn't depend on the status field, so it correctly runs after.
|
|
8951
9792
|
// Logical turn end (normal completion): drop the `[delivered]`
|
|
8952
|
-
// receipt
|
|
8953
|
-
// awaited
|
|
8954
|
-
//
|
|
8955
|
-
//
|
|
9793
|
+
// receipt, once every delivery has settled. The text-only and
|
|
9794
|
+
// trailing paths awaited the chain when there was final text; a
|
|
9795
|
+
// turn whose last response is empty awaits it here, so the
|
|
9796
|
+
// receipt and the continuation see its last rounds' deliveries.
|
|
9797
|
+
// Locus and hybrid mode; explicit-mode envelopes acknowledge
|
|
9798
|
+
// themselves through the prose gateway.
|
|
9799
|
+
await turnSpeechChain;
|
|
8956
9800
|
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
8957
|
-
this.appendProseDeliveryReceipt(agent);
|
|
9801
|
+
this.appendProseDeliveryReceipt(agent, streamRouting);
|
|
8958
9802
|
}
|
|
8959
|
-
//
|
|
8960
|
-
//
|
|
8961
|
-
// now instead of pausing until the next external event. This gives
|
|
8962
|
-
// prose the same continuation ability tool rounds have (wanted for
|
|
9803
|
+
// A `!` continuation this turn asked for. This gives prose the
|
|
9804
|
+
// same continuation ability tool rounds have (wanted for
|
|
8963
9805
|
// robotics-style loops where an end-of-turn pause is harmful).
|
|
8964
|
-
|
|
8965
|
-
console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
|
|
8966
|
-
this.pendingRequests.push({
|
|
8967
|
-
agentName: agent.name,
|
|
8968
|
-
reason: 'prose-continuation',
|
|
8969
|
-
source: 'framework',
|
|
8970
|
-
timestamp: Date.now(),
|
|
8971
|
-
});
|
|
8972
|
-
}
|
|
9806
|
+
this.requestProseContinuation(agent, finalRouting);
|
|
8973
9807
|
break;
|
|
8974
9808
|
}
|
|
8975
9809
|
case 'error': {
|
|
@@ -9016,7 +9850,7 @@ export class AgentFramework {
|
|
|
9016
9850
|
stopReason: 'exhausted',
|
|
9017
9851
|
speech: '',
|
|
9018
9852
|
error: 'Turn abandoned by operator quiesce',
|
|
9019
|
-
});
|
|
9853
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
|
|
9020
9854
|
this.emitTrace({
|
|
9021
9855
|
type: 'inference:aborted',
|
|
9022
9856
|
agentName: agent.name,
|
|
@@ -9045,7 +9879,7 @@ export class AgentFramework {
|
|
|
9045
9879
|
stopReason: 'exhausted',
|
|
9046
9880
|
speech: '',
|
|
9047
9881
|
error: err.message,
|
|
9048
|
-
});
|
|
9882
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
|
|
9049
9883
|
this.emitTrace({
|
|
9050
9884
|
type: 'inference:exhausted',
|
|
9051
9885
|
agentName: agent.name,
|
|
@@ -9100,7 +9934,7 @@ export class AgentFramework {
|
|
|
9100
9934
|
stopReason: 'exhausted',
|
|
9101
9935
|
speech: '',
|
|
9102
9936
|
error: 'Turn abandoned by operator quiesce',
|
|
9103
|
-
});
|
|
9937
|
+
}, streamRouting.logical);
|
|
9104
9938
|
}
|
|
9105
9939
|
this.emitTrace({
|
|
9106
9940
|
type: 'inference:aborted',
|
|
@@ -9113,13 +9947,23 @@ export class AgentFramework {
|
|
|
9113
9947
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
9114
9948
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
9115
9949
|
// so settle the delivery chain and drop the receipt. A
|
|
9116
|
-
// budget restart
|
|
9117
|
-
//
|
|
9118
|
-
// start) and the continuation stream's end writes ONE
|
|
9119
|
-
// receipt for the whole turn.
|
|
9950
|
+
// budget restart keeps the same turn's routing and tallies;
|
|
9951
|
+
// the continuation stream's end writes one receipt for it.
|
|
9120
9952
|
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
9121
9953
|
await turnSpeechChain;
|
|
9122
|
-
this.appendProseDeliveryReceipt(agent);
|
|
9954
|
+
this.appendProseDeliveryReceipt(agent, streamRouting);
|
|
9955
|
+
}
|
|
9956
|
+
// A `!` an earlier round asked for still starts its
|
|
9957
|
+
// continuation when a tool ends the turn, once the turn's
|
|
9958
|
+
// deliveries have run. Explicit mode doesn't hold the turn
|
|
9959
|
+
// for them, so it checks at the end of its chain.
|
|
9960
|
+
if (cancelKind === 'turn_ended') {
|
|
9961
|
+
if (turnProseRouting === 'explicit') {
|
|
9962
|
+
void turnSpeechChain.then(() => this.requestProseContinuation(agent, streamRouting));
|
|
9963
|
+
}
|
|
9964
|
+
else {
|
|
9965
|
+
this.requestProseContinuation(agent, streamRouting);
|
|
9966
|
+
}
|
|
9123
9967
|
}
|
|
9124
9968
|
return;
|
|
9125
9969
|
}
|
|
@@ -9135,7 +9979,7 @@ export class AgentFramework {
|
|
|
9135
9979
|
stopReason: 'exhausted',
|
|
9136
9980
|
speech: '',
|
|
9137
9981
|
error: `Stream aborted: ${reason}`,
|
|
9138
|
-
});
|
|
9982
|
+
}, streamRouting.logical);
|
|
9139
9983
|
this.emitTrace({
|
|
9140
9984
|
type: 'inference:exhausted',
|
|
9141
9985
|
agentName: agent.name,
|
|
@@ -9268,7 +10112,7 @@ export class AgentFramework {
|
|
|
9268
10112
|
stopReason: 'exhausted',
|
|
9269
10113
|
speech: '',
|
|
9270
10114
|
error: err.message,
|
|
9271
|
-
});
|
|
10115
|
+
}, streamRouting.logical);
|
|
9272
10116
|
this.emitTrace({
|
|
9273
10117
|
type: 'inference:exhausted',
|
|
9274
10118
|
agentName: agent.name,
|
|
@@ -9516,13 +10360,15 @@ export class AgentFramework {
|
|
|
9516
10360
|
let ownedToEnd = false;
|
|
9517
10361
|
try {
|
|
9518
10362
|
const started = Date.now();
|
|
9519
|
-
const
|
|
9520
|
-
|
|
9521
|
-
name: toolName,
|
|
9522
|
-
input,
|
|
9523
|
-
callerAgentName: agentName,
|
|
9524
|
-
});
|
|
10363
|
+
const call = { id: toolUseId, name: toolName, input, callerAgentName: agentName };
|
|
10364
|
+
const returned = await this.executeToolCall(call);
|
|
9525
10365
|
const durationMs = Date.now() - started;
|
|
10366
|
+
// The pair is stored as a model's call would be, so the result stored,
|
|
10367
|
+
// traced and logged is its checked form. The caller gets the tool's
|
|
10368
|
+
// own result back, as an in-process caller does, unless nothing
|
|
10369
|
+
// usable could be stored: then it gets the error result stored.
|
|
10370
|
+
const checked = checkedToolResult(call, returned);
|
|
10371
|
+
const result = checked.result;
|
|
9526
10372
|
// Store the pair through the same shapes the ordinary path uses. Build
|
|
9527
10373
|
// the result blocks BEFORE storing the tool_use: the spill path awaits.
|
|
9528
10374
|
// A message arriving during either await is deferred by the turn
|
|
@@ -9560,7 +10406,7 @@ export class AgentFramework {
|
|
|
9560
10406
|
});
|
|
9561
10407
|
console.log(`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
|
|
9562
10408
|
`in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`);
|
|
9563
|
-
return { toolUseId, result };
|
|
10409
|
+
return { toolUseId, result: checked.usable ? returned : result };
|
|
9564
10410
|
}
|
|
9565
10411
|
finally {
|
|
9566
10412
|
if (this.activeTurnTokens.get(agentName) === turnToken)
|
|
@@ -9585,6 +10431,8 @@ export class AgentFramework {
|
|
|
9585
10431
|
}
|
|
9586
10432
|
}
|
|
9587
10433
|
async executeToolCallFrom(call, origin) {
|
|
10434
|
+
if (isPresentationTool(call.name))
|
|
10435
|
+
return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
|
|
9588
10436
|
// Client-side programmatic tool calling for promise-based callers
|
|
9589
10437
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
9590
10438
|
// gets its own interpreter state.
|
|
@@ -9620,9 +10468,10 @@ export class AgentFramework {
|
|
|
9620
10468
|
// ephemerals, ModuleContext.callTool) reach utilities the same way the
|
|
9621
10469
|
// model does.
|
|
9622
10470
|
if (call.name === 'utils') {
|
|
9623
|
-
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
|
|
10471
|
+
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true });
|
|
9624
10472
|
}
|
|
9625
|
-
// Module tools
|
|
10473
|
+
// Module tools. The caller uses the result in process, so it gets the
|
|
10474
|
+
// module's own result, as the module returned it.
|
|
9626
10475
|
return this.moduleRegistry.handleToolCall(call);
|
|
9627
10476
|
}
|
|
9628
10477
|
async executeMcplToolCall(call, config) {
|
|
@@ -9700,6 +10549,7 @@ export class AgentFramework {
|
|
|
9700
10549
|
this.pushEvent({
|
|
9701
10550
|
type: 'tool-result',
|
|
9702
10551
|
callId: call.id,
|
|
10552
|
+
toolName: call.name,
|
|
9703
10553
|
agentName,
|
|
9704
10554
|
moduleName: 'code_execution',
|
|
9705
10555
|
result,
|
|
@@ -9717,6 +10567,11 @@ export class AgentFramework {
|
|
|
9717
10567
|
* results reach the running script and never the model context.
|
|
9718
10568
|
*/
|
|
9719
10569
|
async runCodeExecution(agentName, call) {
|
|
10570
|
+
// RFC-007: what the script's own tool calls are attributed to. Read
|
|
10571
|
+
// before the first await: it is only known while this call is being
|
|
10572
|
+
// dispatched. Undefined off the model dispatch path (ephemeral callers),
|
|
10573
|
+
// whose code_execution call is not reported either.
|
|
10574
|
+
const origin = this.toolLifecycleEmitter?.scriptOrigin(agentName, call.id);
|
|
9720
10575
|
const input = (call.input ?? {});
|
|
9721
10576
|
// Management surface: the agent's own daemon fleet is inspectable and
|
|
9722
10577
|
// killable through the same tool that spawns it.
|
|
@@ -9779,16 +10634,34 @@ export class AgentFramework {
|
|
|
9779
10634
|
: this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
|
|
9780
10635
|
const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
|
|
9781
10636
|
if (input.background === true) {
|
|
9782
|
-
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
|
|
10637
|
+
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs, origin);
|
|
9783
10638
|
if (capNote && started.success && started.data && typeof started.data === 'object') {
|
|
9784
10639
|
started.data.time_limit_note = capNote;
|
|
9785
10640
|
}
|
|
9786
10641
|
return started;
|
|
9787
10642
|
}
|
|
9788
10643
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
9789
|
-
this
|
|
9790
|
-
|
|
9791
|
-
const
|
|
10644
|
+
// A busy runner refuses this exec; the running script keeps its mark and
|
|
10645
|
+
// its origin.
|
|
10646
|
+
const ownsRunner = !runner.busy;
|
|
10647
|
+
const script = { endTurn: false };
|
|
10648
|
+
if (ownsRunner)
|
|
10649
|
+
this.scriptDeferredEndTurn.set(agentName, script);
|
|
10650
|
+
const ownsOrigin = origin !== undefined && ownsRunner;
|
|
10651
|
+
if (ownsOrigin)
|
|
10652
|
+
this.foregroundScriptOrigins.set(agentName, origin);
|
|
10653
|
+
let exec;
|
|
10654
|
+
try {
|
|
10655
|
+
exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
|
|
10656
|
+
}
|
|
10657
|
+
finally {
|
|
10658
|
+
if (this.scriptDeferredEndTurn.get(agentName) === script)
|
|
10659
|
+
this.scriptDeferredEndTurn.delete(agentName);
|
|
10660
|
+
if (ownsOrigin && this.foregroundScriptOrigins.get(agentName) === origin) {
|
|
10661
|
+
this.foregroundScriptOrigins.delete(agentName);
|
|
10662
|
+
}
|
|
10663
|
+
}
|
|
10664
|
+
const endTurn = script.endTurn;
|
|
9792
10665
|
return {
|
|
9793
10666
|
success: true,
|
|
9794
10667
|
data: {
|
|
@@ -9812,7 +10685,7 @@ export class AgentFramework {
|
|
|
9812
10685
|
* limitation is stated in the tool description so agents can calibrate
|
|
9813
10686
|
* how much to rely on an armed wake.
|
|
9814
10687
|
*/
|
|
9815
|
-
startBackgroundScript(agentName, code, injected, timeLimitMs) {
|
|
10688
|
+
startBackgroundScript(agentName, code, injected, timeLimitMs, origin) {
|
|
9816
10689
|
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
9817
10690
|
// would outlive its owner and its wake would land in the primary
|
|
9818
10691
|
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
@@ -9858,7 +10731,7 @@ export class AgentFramework {
|
|
|
9858
10731
|
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
9859
10732
|
idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
|
|
9860
10733
|
label: `${agentName}:${scriptId}`,
|
|
9861
|
-
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
|
|
10734
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, origin),
|
|
9862
10735
|
});
|
|
9863
10736
|
const record = {
|
|
9864
10737
|
id: scriptId,
|
|
@@ -10047,7 +10920,7 @@ export class AgentFramework {
|
|
|
10047
10920
|
// stays reachable by provenance after the bytes leave the wire.
|
|
10048
10921
|
const raw = tc.result.isError
|
|
10049
10922
|
? tc.result.error ?? 'Unknown error'
|
|
10050
|
-
:
|
|
10923
|
+
: nonErrorResultText(tc.result, undefined, {
|
|
10051
10924
|
imageRef: (blockIndex, image) => ledger.retain({
|
|
10052
10925
|
toolCallId: tc.id,
|
|
10053
10926
|
toolName: tc.name,
|
|
@@ -10069,6 +10942,134 @@ export class AgentFramework {
|
|
|
10069
10942
|
}));
|
|
10070
10943
|
return { blocks, spilled };
|
|
10071
10944
|
}
|
|
10945
|
+
/** Where a tool result labelled `label` is written: `<first writable
|
|
10946
|
+
* mount>/tool-results/<label>.txt`, or null when there is no writable
|
|
10947
|
+
* workspace. */
|
|
10948
|
+
toolResultFilePlace(label) {
|
|
10949
|
+
const workspace = this.getWorkspaceModule();
|
|
10950
|
+
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
10951
|
+
if (!workspace || !mountName)
|
|
10952
|
+
return null;
|
|
10953
|
+
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
10954
|
+
return { workspace, path: `${mountName}/tool-results/${safeLabel}.txt`, safeLabel };
|
|
10955
|
+
}
|
|
10956
|
+
/**
|
|
10957
|
+
* A withheld original uses the remaining time in its batch's deadline.
|
|
10958
|
+
* A write still under way at that deadline is reported as unfinished and
|
|
10959
|
+
* goes on; the guard stops waiting for it, though stop() still waits for
|
|
10960
|
+
* the workspace's turn it holds (bounded only by a teardown timeout). From
|
|
10961
|
+
* then until every
|
|
10962
|
+
* overdue write settles, new withheld spills are workspace_unanswered.
|
|
10963
|
+
* Writes from other batches can start before the first deadline fires.
|
|
10964
|
+
* An exhausted batch starts no more writes even if none is overdue.
|
|
10965
|
+
* The inline cap's spills use writeToolResultFile without this deadline.
|
|
10966
|
+
*/
|
|
10967
|
+
async spillWithheld(label, text, branch, deadline = Date.now() + this.withheldSpillDeadlineMs) {
|
|
10968
|
+
// A batch with no finite deadline (a host whose deadline() threw) gets
|
|
10969
|
+
// this write's own, rather than a timer Node would cut to 1 ms.
|
|
10970
|
+
if (!Number.isFinite(deadline))
|
|
10971
|
+
deadline = Date.now() + this.withheldSpillDeadlineMs;
|
|
10972
|
+
if (this.guardSpillsClosedFor === this.lifecycleGeneration)
|
|
10973
|
+
return null;
|
|
10974
|
+
const place = this.toolResultFilePlace(label);
|
|
10975
|
+
if (!place)
|
|
10976
|
+
return null;
|
|
10977
|
+
if (this.stalledSpills.size > 0) {
|
|
10978
|
+
// Said once per stall, with the count when it clears, so a stalled
|
|
10979
|
+
// workspace doesn't write a line for every result it can't take.
|
|
10980
|
+
if (this.stallSkips++ === 0) {
|
|
10981
|
+
console.error(`[spill] withheld originals are skipped as workspace_unanswered while ${this.stalledSpills.size} write(s) past their deadline are under way (first: ${place.path})`);
|
|
10982
|
+
}
|
|
10983
|
+
return { skipped: 'workspace_unanswered' };
|
|
10984
|
+
}
|
|
10985
|
+
const remaining = deadline - Date.now();
|
|
10986
|
+
// The guard says once per batch that it used its deadline.
|
|
10987
|
+
if (remaining <= 0)
|
|
10988
|
+
return { skipped: 'batch_deadline' };
|
|
10989
|
+
const write = this.writeToolResultFile(label, text, branch);
|
|
10990
|
+
let timer;
|
|
10991
|
+
const late = new Promise((resolve) => {
|
|
10992
|
+
timer = setTimeout(() => {
|
|
10993
|
+
// Marked as the deadline passes, before anything else runs, so no
|
|
10994
|
+
// spill that starts after it finds the workspace answering.
|
|
10995
|
+
const stalled = write.then(() => { }, () => { }).finally(() => {
|
|
10996
|
+
this.stalledSpills.delete(stalled);
|
|
10997
|
+
if (this.stalledSpills.size === 0 && this.stallSkips > 0) {
|
|
10998
|
+
console.error(`[spill] the workspace answered again; ${this.stallSkips} withheld original(s) were skipped as workspace_unanswered meanwhile`);
|
|
10999
|
+
this.stallSkips = 0;
|
|
11000
|
+
}
|
|
11001
|
+
});
|
|
11002
|
+
this.stalledSpills.add(stalled);
|
|
11003
|
+
console.error(`[spill] ${place.path} still being written at the withheld batch deadline`);
|
|
11004
|
+
resolve('late');
|
|
11005
|
+
}, remaining);
|
|
11006
|
+
timer.unref?.();
|
|
11007
|
+
});
|
|
11008
|
+
try {
|
|
11009
|
+
const written = await Promise.race([write, late]);
|
|
11010
|
+
if (written !== 'late') {
|
|
11011
|
+
if (!written)
|
|
11012
|
+
return null;
|
|
11013
|
+
if (written.branchLeft)
|
|
11014
|
+
return { skipped: 'branch_changed' };
|
|
11015
|
+
return { path: written.path, ...(written.error !== undefined ? { error: written.error } : {}) };
|
|
11016
|
+
}
|
|
11017
|
+
return { path: place.path, unfinished: true };
|
|
11018
|
+
}
|
|
11019
|
+
finally {
|
|
11020
|
+
clearTimeout(timer);
|
|
11021
|
+
}
|
|
11022
|
+
}
|
|
11023
|
+
/**
|
|
11024
|
+
* Write one tool result's text to its tool-results file (see
|
|
11025
|
+
* toolResultFilePlace): the one place both the inline cap's spill and the
|
|
11026
|
+
* guard's withheld originals go. null when there is no writable workspace.
|
|
11027
|
+
* A failed write is reported, traced and logged here, never thrown. With a
|
|
11028
|
+
* branch, the file lands on that branch or isn't written (see writeBinary).
|
|
11029
|
+
*/
|
|
11030
|
+
async writeToolResultFile(label, content, branch) {
|
|
11031
|
+
const place = this.toolResultFilePlace(label);
|
|
11032
|
+
if (!place)
|
|
11033
|
+
return null;
|
|
11034
|
+
const { workspace, path, safeLabel } = place;
|
|
11035
|
+
let failure;
|
|
11036
|
+
try {
|
|
11037
|
+
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain', { branch });
|
|
11038
|
+
if (result.success)
|
|
11039
|
+
return { path };
|
|
11040
|
+
// The file is in the workspace's Chronicle tree, where its tools read
|
|
11041
|
+
// it, and the rest of the write failed (its copy on disk, or something
|
|
11042
|
+
// after the entry was set): it is written for every reader the stub or
|
|
11043
|
+
// the truncation note sends there. The workspace emits
|
|
11044
|
+
// workspace:materialize-failed only for a refused push, so the failure
|
|
11045
|
+
// is traced here too.
|
|
11046
|
+
if (result.data?.inChronicle === true) {
|
|
11047
|
+
const error = (result.error ?? 'the rest of the write failed').slice(0, 200);
|
|
11048
|
+
this.emitTrace({ type: 'tool:spill_failed', label: safeLabel, path, contentLength: content.length, error });
|
|
11049
|
+
console.error(`[spill] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${error}`);
|
|
11050
|
+
return { path };
|
|
11051
|
+
}
|
|
11052
|
+
// Declined, not failed: the store left the branch the file is for, so
|
|
11053
|
+
// it isn't written anywhere, as the caller asked.
|
|
11054
|
+
if (result.data?.branchLeft === true) {
|
|
11055
|
+
return { path, error: result.error ?? 'the workspace had left the branch this file is for', branchLeft: true };
|
|
11056
|
+
}
|
|
11057
|
+
failure = result.error ?? 'write refused';
|
|
11058
|
+
}
|
|
11059
|
+
catch (err) {
|
|
11060
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
11061
|
+
}
|
|
11062
|
+
failure = failure.slice(0, 200);
|
|
11063
|
+
this.emitTrace({
|
|
11064
|
+
type: 'tool:spill_failed',
|
|
11065
|
+
label: safeLabel,
|
|
11066
|
+
path,
|
|
11067
|
+
contentLength: content.length,
|
|
11068
|
+
error: failure,
|
|
11069
|
+
});
|
|
11070
|
+
console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
|
|
11071
|
+
return { path, error: failure };
|
|
11072
|
+
}
|
|
10072
11073
|
async spillOrTruncate(content, cap, label) {
|
|
10073
11074
|
if (!cap || content.length <= cap)
|
|
10074
11075
|
return { text: content, filePath: null };
|
|
@@ -10079,51 +11080,32 @@ export class AgentFramework {
|
|
|
10079
11080
|
// slots at or past the cut, re-appended after the notice.
|
|
10080
11081
|
const { head, tail } = splitPreservingImageSlots(content, cap);
|
|
10081
11082
|
const kept = formatPreservedImageSlots(tail);
|
|
10082
|
-
const
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
10088
|
-
|
|
10089
|
-
|
|
10090
|
-
|
|
10091
|
-
|
|
10092
|
-
|
|
10093
|
-
|
|
10094
|
-
|
|
10095
|
-
|
|
10096
|
-
|
|
10097
|
-
|
|
10098
|
-
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
10102
|
-
+ kept,
|
|
10103
|
-
filePath: path,
|
|
10104
|
-
};
|
|
10105
|
-
}
|
|
10106
|
-
failure = result.error ?? 'write refused';
|
|
10107
|
-
}
|
|
10108
|
-
catch (err) {
|
|
10109
|
-
failure = err instanceof Error ? err.message : String(err);
|
|
10110
|
-
}
|
|
11083
|
+
const written = await this.writeToolResultFile(label, content);
|
|
11084
|
+
if (written && written.error === undefined) {
|
|
11085
|
+
const path = written.path;
|
|
11086
|
+
// JSON serialization can expand each read-back code unit to six
|
|
11087
|
+
// chars (\\u0000). Leave room for the page's metadata and a possible
|
|
11088
|
+
// extra code unit at a surrogate-pair boundary.
|
|
11089
|
+
const pageOverhead = JSON.stringify(path).length + 256;
|
|
11090
|
+
const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
|
|
11091
|
+
return {
|
|
11092
|
+
text: head
|
|
11093
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
|
|
11094
|
+
+ `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
|
|
11095
|
+
+ 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
|
|
11096
|
+
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
11097
|
+
+ kept,
|
|
11098
|
+
filePath: path,
|
|
11099
|
+
};
|
|
11100
|
+
}
|
|
11101
|
+
if (written) {
|
|
10111
11102
|
// A workspace was there and the write FAILED — say that, loudly and
|
|
10112
11103
|
// distinctly. Telling the agent "no writable workspace" would teach
|
|
10113
11104
|
// them their residence lacks a capability it actually has.
|
|
10114
|
-
failure = failure.slice(0, 200);
|
|
10115
|
-
this.emitTrace({
|
|
10116
|
-
type: 'tool:spill_failed',
|
|
10117
|
-
label: safeLabel,
|
|
10118
|
-
path,
|
|
10119
|
-
contentLength: content.length,
|
|
10120
|
-
error: failure,
|
|
10121
|
-
});
|
|
10122
|
-
console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
|
|
10123
11105
|
return {
|
|
10124
11106
|
text: head
|
|
10125
|
-
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
10126
|
-
+ ` (${
|
|
11107
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
|
|
11108
|
+
+ ` (${written.error}); content over the cap was not retained]`
|
|
10127
11109
|
+ kept,
|
|
10128
11110
|
filePath: null,
|
|
10129
11111
|
};
|
|
@@ -10145,7 +11127,8 @@ export class AgentFramework {
|
|
|
10145
11127
|
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
10146
11128
|
idleReclaimMs: cfg?.idleReclaimMs,
|
|
10147
11129
|
label: agentName,
|
|
10148
|
-
|
|
11130
|
+
// The script that made the call is the one running when it arrives.
|
|
11131
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, this.foregroundScriptOrigins.get(agentName), this.scriptDeferredEndTurn.get(agentName)),
|
|
10149
11132
|
});
|
|
10150
11133
|
this.codeExecutionRunners.set(agentName, runner);
|
|
10151
11134
|
}
|
|
@@ -10160,22 +11143,24 @@ export class AgentFramework {
|
|
|
10160
11143
|
* placeholders): programmatic tool results are text-only by contract, and
|
|
10161
11144
|
* a script variable holding megabytes of base64 helps nobody.
|
|
10162
11145
|
*/
|
|
10163
|
-
async handleScriptToolCall(agentName, toolName, args
|
|
11146
|
+
async handleScriptToolCall(agentName, toolName, args, origin,
|
|
11147
|
+
/** The foreground script that made the call; none for a background script. */
|
|
11148
|
+
script) {
|
|
10164
11149
|
if (toolName === CODE_EXECUTION_TOOL_NAME) {
|
|
10165
11150
|
return 'Error: code_execution cannot be called from within a script';
|
|
10166
11151
|
}
|
|
10167
|
-
const result = await this.dispatchScriptToolCall(agentName, toolName, args);
|
|
10168
|
-
if (result.endTurn) {
|
|
11152
|
+
const result = await this.dispatchScriptToolCall(agentName, toolName, args, origin);
|
|
11153
|
+
if (result.endTurn && script) {
|
|
10169
11154
|
// Deferred: applied to the final code_execution result (see
|
|
10170
11155
|
// scriptDeferredEndTurn) — ending the turn mid-script would cancel the
|
|
10171
11156
|
// stream while the script still runs and wedge the tool round.
|
|
10172
|
-
|
|
11157
|
+
script.endTurn = true;
|
|
10173
11158
|
}
|
|
10174
11159
|
if (result.isError) {
|
|
10175
11160
|
return `Error: ${result.error ?? 'tool call failed'}`;
|
|
10176
11161
|
}
|
|
10177
|
-
if (result.data === undefined)
|
|
10178
|
-
return
|
|
11162
|
+
if (result.data === undefined || result.data === null)
|
|
11163
|
+
return nonErrorResultText(result, 5_000_000);
|
|
10179
11164
|
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
|
|
10180
11165
|
// available signal that the bytes are wanted (scripts process, they do
|
|
10181
11166
|
// not browse) — materialize every referenced payload now, so the stub
|
|
@@ -10203,8 +11188,12 @@ export class AgentFramework {
|
|
|
10203
11188
|
* resolve with its result. The waiter intercept in handleProcessEvent
|
|
10204
11189
|
* (keyed by the pytc- call ID) routes the tool-result event here instead
|
|
10205
11190
|
* of into the agent's pending tool round.
|
|
11191
|
+
*
|
|
11192
|
+
* RFC-007: with an `origin`, observers see the call as they see a model
|
|
11193
|
+
* call (register, dispatch with its refusal sites, open; the terminal at
|
|
11194
|
+
* the waiter intercept), under the inner tool's own name and class.
|
|
10206
11195
|
*/
|
|
10207
|
-
dispatchScriptToolCall(agentName, toolName, args) {
|
|
11196
|
+
dispatchScriptToolCall(agentName, toolName, args, origin) {
|
|
10208
11197
|
return new Promise((resolve) => {
|
|
10209
11198
|
const callId = `pytc-${randomUUID()}`;
|
|
10210
11199
|
// Host-side leak guard: the python runtime already raises TimeoutError
|
|
@@ -10213,6 +11202,9 @@ export class AgentFramework {
|
|
|
10213
11202
|
const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
|
|
10214
11203
|
const safety = setTimeout(() => {
|
|
10215
11204
|
if (this.scriptToolWaiters.delete(callId)) {
|
|
11205
|
+
// RFC-007: no result reached the host — the call failed.
|
|
11206
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
|
|
11207
|
+
this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
|
|
10216
11208
|
resolve({
|
|
10217
11209
|
success: false,
|
|
10218
11210
|
error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
|
|
@@ -10225,16 +11217,31 @@ export class AgentFramework {
|
|
|
10225
11217
|
clearTimeout(safety);
|
|
10226
11218
|
resolve(result);
|
|
10227
11219
|
});
|
|
11220
|
+
const call = { id: callId, name: toolName, input: args };
|
|
11221
|
+
if (origin)
|
|
11222
|
+
this.toolLifecycleEmitter?.registerScriptCall(agentName, origin, call);
|
|
10228
11223
|
try {
|
|
10229
|
-
this.dispatchToolCall(agentName,
|
|
11224
|
+
this.dispatchToolCall(agentName, call);
|
|
10230
11225
|
}
|
|
10231
11226
|
catch (error) {
|
|
11227
|
+
// Dispatch threw. Not a refusal (refusal sites call refuse() and
|
|
11228
|
+
// answer): the tool may have done work first (sleep sets the gate,
|
|
11229
|
+
// then formats its reply), so the call started and the host got no
|
|
11230
|
+
// result — `failed`, as for a dispatch that rejects.
|
|
11231
|
+
if (origin) {
|
|
11232
|
+
this.toolLifecycleEmitter?.open(agentName, callId);
|
|
11233
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
|
|
11234
|
+
this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
|
|
11235
|
+
}
|
|
10232
11236
|
if (this.scriptToolWaiters.delete(callId)) {
|
|
10233
11237
|
clearTimeout(safety);
|
|
10234
11238
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
10235
11239
|
resolve({ success: false, error: err.message, isError: true });
|
|
10236
11240
|
}
|
|
11241
|
+
return;
|
|
10237
11242
|
}
|
|
11243
|
+
if (origin)
|
|
11244
|
+
this.toolLifecycleEmitter?.open(agentName, callId);
|
|
10238
11245
|
});
|
|
10239
11246
|
}
|
|
10240
11247
|
/** Abort a running script when the agent's turn dies underneath it. */
|
|
@@ -10565,6 +11572,11 @@ export class AgentFramework {
|
|
|
10565
11572
|
dispatchToolCall(agentName, call) {
|
|
10566
11573
|
// Enrich call with caller identity so modules can resolve the calling agent
|
|
10567
11574
|
const enrichedCall = { ...call, callerAgentName: agentName };
|
|
11575
|
+
if (isPresentationTool(call.name)) {
|
|
11576
|
+
const result = this.editToolPresentation(agentName, enrichedCall);
|
|
11577
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tool-presentation', result });
|
|
11578
|
+
return;
|
|
11579
|
+
}
|
|
10568
11580
|
// Route MCPL tool calls to the appropriate server via prefix map
|
|
10569
11581
|
const mcplMatch = this.resolveMcplTool(enrichedCall.name);
|
|
10570
11582
|
if (mcplMatch && this.mcplServerRegistry) {
|
|
@@ -10612,10 +11624,16 @@ export class AgentFramework {
|
|
|
10612
11624
|
SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
|
|
10613
11625
|
void this.tuneOutCoordinator
|
|
10614
11626
|
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
|
|
11627
|
+
// A rejection is answered with a failed result, as a framework tool's
|
|
11628
|
+
// dispatch failure is (dispatchUtilsToolCall), never left unhandled.
|
|
11629
|
+
.catch((err) => ({ success: false, error: thrownText(err), isError: true }))
|
|
10615
11630
|
.then((result) => {
|
|
10616
|
-
|
|
11631
|
+
// Through pushEvent, like every other tool's result, so one that
|
|
11632
|
+
// arrives after stop() is dropped rather than rejected unhandled.
|
|
11633
|
+
this.pushEvent({
|
|
10617
11634
|
type: 'tool-result',
|
|
10618
11635
|
callId: enrichedCall.id,
|
|
11636
|
+
toolName: enrichedCall.name,
|
|
10619
11637
|
agentName,
|
|
10620
11638
|
moduleName: 'tune-out',
|
|
10621
11639
|
result,
|
|
@@ -10681,6 +11699,7 @@ export class AgentFramework {
|
|
|
10681
11699
|
this.pushEvent({
|
|
10682
11700
|
type: 'tool-result',
|
|
10683
11701
|
callId: enrichedCall.id,
|
|
11702
|
+
toolName: enrichedCall.name,
|
|
10684
11703
|
agentName,
|
|
10685
11704
|
moduleName: 'framework',
|
|
10686
11705
|
result: { success: true, data: PROSE_ROUTING_HELP },
|
|
@@ -10710,7 +11729,7 @@ export class AgentFramework {
|
|
|
10710
11729
|
});
|
|
10711
11730
|
const startTime = Date.now();
|
|
10712
11731
|
this.moduleRegistry
|
|
10713
|
-
.
|
|
11732
|
+
.storableToolCall({ ...call, callerAgentName: agentName })
|
|
10714
11733
|
.then((result) => {
|
|
10715
11734
|
const durationMs = Date.now() - startTime;
|
|
10716
11735
|
this.emitTrace({
|
|
@@ -10723,6 +11742,7 @@ export class AgentFramework {
|
|
|
10723
11742
|
this.pushEvent({
|
|
10724
11743
|
type: 'tool-result',
|
|
10725
11744
|
callId: call.id,
|
|
11745
|
+
toolName: call.name,
|
|
10726
11746
|
agentName,
|
|
10727
11747
|
moduleName,
|
|
10728
11748
|
result,
|
|
@@ -10743,6 +11763,7 @@ export class AgentFramework {
|
|
|
10743
11763
|
this.pushEvent({
|
|
10744
11764
|
type: 'tool-result',
|
|
10745
11765
|
callId: call.id,
|
|
11766
|
+
toolName: call.name,
|
|
10746
11767
|
agentName,
|
|
10747
11768
|
moduleName,
|
|
10748
11769
|
result: {
|
|
@@ -11267,7 +12288,7 @@ export class AgentFramework {
|
|
|
11267
12288
|
*/
|
|
11268
12289
|
async initializeMcpl(serverConfigs, inferenceRouting) {
|
|
11269
12290
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
11270
|
-
this.featureSetManager = new FeatureSetManager();
|
|
12291
|
+
this.featureSetManager = new FeatureSetManager((event) => this.emitTrace(event));
|
|
11271
12292
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
11272
12293
|
this.toolLifecycleEmitter = new ToolLifecycleEmitter({
|
|
11273
12294
|
observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
|
|
@@ -11782,6 +12803,18 @@ export class AgentFramework {
|
|
|
11782
12803
|
* coalescing. */
|
|
11783
12804
|
manifestRefreshState = new Map();
|
|
11784
12805
|
static MANIFEST_FETCH_FLOOR_MS = 5_000;
|
|
12806
|
+
/**
|
|
12807
|
+
* The `mcpl:policy-refused` fields from a refusal receipt, as the host
|
|
12808
|
+
* acts on them: the receipt is server-supplied, so `fallback` is the
|
|
12809
|
+
* branch actually taken (anything but `close` is treated as `mcp-only`)
|
|
12810
|
+
* and a non-string `reason` is dropped.
|
|
12811
|
+
*/
|
|
12812
|
+
static policyRefusalFields(receipt) {
|
|
12813
|
+
return {
|
|
12814
|
+
fallback: receipt.fallback === 'close' ? 'close' : 'mcp-only',
|
|
12815
|
+
reason: typeof receipt.reason === 'string' ? receipt.reason : null,
|
|
12816
|
+
};
|
|
12817
|
+
}
|
|
11785
12818
|
/**
|
|
11786
12819
|
* §17.5 host processing: fetch the complete manifest, validate exactly as
|
|
11787
12820
|
* at initialize, diff, apply §6.7 consequences (removals eagerly even
|
|
@@ -11885,6 +12918,12 @@ export class AgentFramework {
|
|
|
11885
12918
|
if (receipt && receipt.accepted === false) {
|
|
11886
12919
|
const fallback = receipt.fallback ?? 'mcp-only';
|
|
11887
12920
|
console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
|
|
12921
|
+
this.emitTrace({
|
|
12922
|
+
type: 'mcpl:policy-refused',
|
|
12923
|
+
serverId: connection.id,
|
|
12924
|
+
phase: 'manifest-change',
|
|
12925
|
+
...AgentFramework.policyRefusalFields(receipt),
|
|
12926
|
+
});
|
|
11888
12927
|
if (fallback === 'close') {
|
|
11889
12928
|
await connection.close();
|
|
11890
12929
|
return;
|
|
@@ -11982,6 +13021,12 @@ export class AgentFramework {
|
|
|
11982
13021
|
if (receipt && receipt.accepted === false) {
|
|
11983
13022
|
const fallback = receipt.fallback ?? 'mcp-only';
|
|
11984
13023
|
console.error(`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`);
|
|
13024
|
+
this.emitTrace({
|
|
13025
|
+
type: 'mcpl:policy-refused',
|
|
13026
|
+
serverId: config.id,
|
|
13027
|
+
phase: 'initial',
|
|
13028
|
+
...AgentFramework.policyRefusalFields(receipt),
|
|
13029
|
+
});
|
|
11985
13030
|
if (fallback === 'close') {
|
|
11986
13031
|
await connection.close();
|
|
11987
13032
|
return;
|
|
@@ -12160,39 +13205,88 @@ export class AgentFramework {
|
|
|
12160
13205
|
// framework; the ScopeManager and config `scopes` wiring went with it.
|
|
12161
13206
|
// Handle push events (Step 6)
|
|
12162
13207
|
connection.on('push-event', async (params, responder) => {
|
|
12163
|
-
const
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
13208
|
+
const answer = answerOnce(responder, 'push/event', connection.id);
|
|
13209
|
+
try {
|
|
13210
|
+
const barrier = this.discordAwarenessBarrier;
|
|
13211
|
+
if (barrier)
|
|
13212
|
+
await barrier.promise;
|
|
13213
|
+
// Checked here, after the barrier and before anything records the
|
|
13214
|
+
// push as accepted: a stopped host refuses it.
|
|
13215
|
+
if (this.queue?.isClosed) {
|
|
13216
|
+
this.refuseServerItem('push/event', connection.id, answer, { kind: 'refused' });
|
|
13217
|
+
return;
|
|
13218
|
+
}
|
|
13219
|
+
await this.pushHandler?.handlePushEvent(connection.id, params, answer.responder);
|
|
13220
|
+
}
|
|
13221
|
+
catch (error) {
|
|
13222
|
+
this.refuseServerItem('push/event', connection.id, answer, { kind: 'failed', error });
|
|
13223
|
+
}
|
|
12167
13224
|
});
|
|
12168
13225
|
// Handle server-initiated inference requests (Step 6)
|
|
12169
13226
|
connection.on('inference-request', async (params, responder) => {
|
|
12170
13227
|
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
|
|
12171
13228
|
if (this.pushCoalescer?.isRendering(connection.id)) {
|
|
12172
|
-
|
|
13229
|
+
try {
|
|
13230
|
+
responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
|
|
13231
|
+
}
|
|
13232
|
+
catch {
|
|
13233
|
+
// The connection is going; there is no one left to tell.
|
|
13234
|
+
}
|
|
12173
13235
|
return;
|
|
12174
13236
|
}
|
|
12175
13237
|
if (this.inferenceRouter && responder) {
|
|
12176
|
-
const
|
|
12177
|
-
|
|
12178
|
-
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
|
|
12182
|
-
|
|
12183
|
-
|
|
13238
|
+
const answer = answerOnce(responder, 'inference/request', connection.id);
|
|
13239
|
+
try {
|
|
13240
|
+
const barrier = this.discordAwarenessBarrier;
|
|
13241
|
+
if (barrier)
|
|
13242
|
+
await barrier.promise;
|
|
13243
|
+
// A stopping host starts no inference for a server: refused, like a
|
|
13244
|
+
// push, before the router runs.
|
|
13245
|
+
if (this.queue?.isClosed) {
|
|
13246
|
+
this.refuseServerItem('inference/request', connection.id, answer, { kind: 'refused' });
|
|
13247
|
+
return;
|
|
13248
|
+
}
|
|
13249
|
+
await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
|
|
13250
|
+
respond: answer.responder.respond,
|
|
13251
|
+
respondError: answer.responder.respondError,
|
|
13252
|
+
requestId: responder.id,
|
|
13253
|
+
});
|
|
13254
|
+
}
|
|
13255
|
+
catch (error) {
|
|
13256
|
+
this.refuseServerItem('inference/request', connection.id, answer, { kind: 'failed', error });
|
|
13257
|
+
}
|
|
12184
13258
|
}
|
|
12185
13259
|
});
|
|
12186
13260
|
// Handle channel registration (Step 7)
|
|
12187
13261
|
connection.on('channels-register', async (params, responder) => {
|
|
12188
|
-
|
|
13262
|
+
const answer = answerOnce(responder, 'channels/register', connection.id);
|
|
13263
|
+
try {
|
|
13264
|
+
if (this.queue?.isClosed) {
|
|
13265
|
+
this.refuseServerItem('channels/register', connection.id, answer, { kind: 'refused' });
|
|
13266
|
+
return;
|
|
13267
|
+
}
|
|
13268
|
+
await this.channelRegistry?.handleRegister(connection.id, params, answer.responder);
|
|
13269
|
+
}
|
|
13270
|
+
catch (error) {
|
|
13271
|
+
this.refuseServerItem('channels/register', connection.id, answer, { kind: 'failed', error });
|
|
13272
|
+
}
|
|
12189
13273
|
});
|
|
12190
13274
|
// Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
|
|
12191
13275
|
// present for the Request form and carries itemized added-descriptor
|
|
12192
13276
|
// results; absent for Notifications, where rejection is itemwise
|
|
12193
13277
|
// filtering plus a diagnostic trace.
|
|
12194
13278
|
connection.on('channels-changed', async (params, responder) => {
|
|
12195
|
-
|
|
13279
|
+
const answer = answerOnce(responder, 'channels/changed', connection.id);
|
|
13280
|
+
try {
|
|
13281
|
+
if (this.queue?.isClosed) {
|
|
13282
|
+
this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'refused' });
|
|
13283
|
+
return;
|
|
13284
|
+
}
|
|
13285
|
+
await this.channelRegistry?.handleChanged(connection.id, params, answer.responder);
|
|
13286
|
+
}
|
|
13287
|
+
catch (error) {
|
|
13288
|
+
this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'failed', error });
|
|
13289
|
+
}
|
|
12196
13290
|
});
|
|
12197
13291
|
// §6.6/§12: requests that previously had no handler and hung forever
|
|
12198
13292
|
// (AUDIT-001 item 4). The connection-level grant gate has already run.
|
|
@@ -12235,25 +13329,41 @@ export class AgentFramework {
|
|
|
12235
13329
|
});
|
|
12236
13330
|
// Handle incoming channel messages (Step 7)
|
|
12237
13331
|
connection.on('channels-incoming', async (params, responder) => {
|
|
12238
|
-
const
|
|
12239
|
-
|
|
12240
|
-
|
|
12241
|
-
|
|
13332
|
+
const answer = answerOnce(responder, 'channels/incoming', connection.id);
|
|
13333
|
+
try {
|
|
13334
|
+
const barrier = this.discordAwarenessBarrier;
|
|
13335
|
+
if (barrier)
|
|
13336
|
+
await barrier.promise;
|
|
13337
|
+
if (this.queue?.isClosed) {
|
|
13338
|
+
this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'refused' });
|
|
13339
|
+
return;
|
|
13340
|
+
}
|
|
13341
|
+
await this.channelRegistry?.handleIncoming(connection.id, params, answer.responder);
|
|
13342
|
+
}
|
|
13343
|
+
catch (error) {
|
|
13344
|
+
this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'failed', error });
|
|
13345
|
+
}
|
|
12242
13346
|
});
|
|
12243
13347
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
12244
13348
|
connection.on('host-command', async (params, responder) => {
|
|
12245
13349
|
if (!responder)
|
|
12246
13350
|
return;
|
|
13351
|
+
const answer = answerOnce(responder, 'host/command', connection.id);
|
|
12247
13352
|
try {
|
|
12248
13353
|
const barrier = this.discordAwarenessBarrier;
|
|
12249
13354
|
if (barrier)
|
|
12250
13355
|
await barrier.promise;
|
|
13356
|
+
// A stopping host runs no host command: refused like the requests
|
|
13357
|
+
// above, before its handler runs.
|
|
13358
|
+
if (this.queue?.isClosed) {
|
|
13359
|
+
this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
|
|
13360
|
+
return;
|
|
13361
|
+
}
|
|
12251
13362
|
const result = await this.handleHostCommand(connection.id, params ?? {});
|
|
12252
|
-
responder.respond(result);
|
|
13363
|
+
answer.responder.respond(result);
|
|
12253
13364
|
}
|
|
12254
13365
|
catch (error) {
|
|
12255
|
-
|
|
12256
|
-
responder.respondError(-32603, err.message);
|
|
13366
|
+
this.refuseServerItem('host/command', connection.id, answer, { kind: 'failed', error });
|
|
12257
13367
|
}
|
|
12258
13368
|
});
|
|
12259
13369
|
// Handle dynamic tool list changes (notifications/tools/list_changed)
|
|
@@ -12399,11 +13509,13 @@ export class AgentFramework {
|
|
|
12399
13509
|
const toolFeatureSets = new Map();
|
|
12400
13510
|
const toolClasses = new Map();
|
|
12401
13511
|
const toolServers = new Map();
|
|
13512
|
+
const listedServers = new Set();
|
|
12402
13513
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
12403
13514
|
const config = this.mcplServerConfigs.get(server.id);
|
|
12404
13515
|
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
12405
13516
|
try {
|
|
12406
13517
|
const result = await server.sendToolsList();
|
|
13518
|
+
listedServers.add(server.id);
|
|
12407
13519
|
for (const tool of result.tools) {
|
|
12408
13520
|
if (!isToolAllowed(tool.name, config))
|
|
12409
13521
|
continue;
|
|
@@ -12433,13 +13545,106 @@ export class AgentFramework {
|
|
|
12433
13545
|
}
|
|
12434
13546
|
}
|
|
12435
13547
|
catch {
|
|
12436
|
-
// Server may not support tools/list — skip silently
|
|
13548
|
+
// Server may not support tools/list — skip silently. A live server
|
|
13549
|
+
// whose handshake advertised no MCP `tools` has, in effect, listed
|
|
13550
|
+
// nothing; count it as listed so tool-pattern checks do not wait on
|
|
13551
|
+
// it forever. (A server that advertised tools but failed to list
|
|
13552
|
+
// them stays unknown.)
|
|
13553
|
+
if (server.isConnected && !server.mcpToolsAdvertised)
|
|
13554
|
+
listedServers.add(server.id);
|
|
12437
13555
|
}
|
|
12438
13556
|
}
|
|
12439
13557
|
this.mcplTools = tools;
|
|
12440
13558
|
this.mcplToolFeatureSets = toolFeatureSets;
|
|
12441
13559
|
this.mcplToolClasses = toolClasses;
|
|
12442
13560
|
this.mcplToolServers = toolServers;
|
|
13561
|
+
this.mcplListedServers = listedServers;
|
|
13562
|
+
if (this.toolPatternChecksArmed)
|
|
13563
|
+
this.checkToolPatterns();
|
|
13564
|
+
}
|
|
13565
|
+
/**
|
|
13566
|
+
* Report operator tool-name patterns that match no model-facing tool:
|
|
13567
|
+
* `toolClassOverrides` keys and each server's `toolLifecycle.observe.tools`
|
|
13568
|
+
* / `toolLifecycle.inputs.tools`. These match `<toolPrefix>--<tool>`, and
|
|
13569
|
+
* the default prefix is `mcpl--<serverId>`, so a pattern written against
|
|
13570
|
+
* the bare server id is valid config that silently does nothing.
|
|
13571
|
+
*
|
|
13572
|
+
* Each pattern is reported at most once (console line + a
|
|
13573
|
+
* `mcpl:tool-pattern-unmatched` trace). A pattern that could still name
|
|
13574
|
+
* tools of a server whose listing is not in (not connected yet,
|
|
13575
|
+
* reconnecting, restarting) stays undecided until that listing arrives.
|
|
13576
|
+
* Diagnostic only: never throws into the refresh that called it.
|
|
13577
|
+
*/
|
|
13578
|
+
checkToolPatterns() {
|
|
13579
|
+
try {
|
|
13580
|
+
const entries = [];
|
|
13581
|
+
for (const [pattern] of this.toolClassOverrides ?? []) {
|
|
13582
|
+
entries.push({ setting: 'toolClassOverrides', pattern });
|
|
13583
|
+
}
|
|
13584
|
+
const configs = [...(this.mcplServerConfigs?.values() ?? [])];
|
|
13585
|
+
for (const config of configs) {
|
|
13586
|
+
const lifecycle = config.toolLifecycle;
|
|
13587
|
+
for (const key of ['observe', 'inputs']) {
|
|
13588
|
+
const tools = lifecycle?.[key]?.tools;
|
|
13589
|
+
if (!Array.isArray(tools))
|
|
13590
|
+
continue;
|
|
13591
|
+
for (const pattern of tools) {
|
|
13592
|
+
if (typeof pattern === 'string') {
|
|
13593
|
+
entries.push({ setting: `toolLifecycle.${key}.tools`, serverId: config.id, pattern });
|
|
13594
|
+
}
|
|
13595
|
+
}
|
|
13596
|
+
}
|
|
13597
|
+
}
|
|
13598
|
+
const reported = (this.toolPatternsReported ??= new Set());
|
|
13599
|
+
const keyOf = (e) => `${e.serverId ?? ''}\u0000${e.setting}\u0000${e.pattern}`;
|
|
13600
|
+
const pending = entries.filter((e) => !reported.has(keyOf(e)));
|
|
13601
|
+
if (pending.length === 0)
|
|
13602
|
+
return;
|
|
13603
|
+
const known = (this.toolPatternKnownPrefixes ??= new Map());
|
|
13604
|
+
for (const c of configs)
|
|
13605
|
+
known.set(c.toolPrefix ?? `mcpl--${c.id}`, c.id);
|
|
13606
|
+
const servers = [...known].map(([prefix, id]) => {
|
|
13607
|
+
const current = this.mcplServerConfigs?.get(id);
|
|
13608
|
+
return {
|
|
13609
|
+
id,
|
|
13610
|
+
prefix,
|
|
13611
|
+
listed: !!current
|
|
13612
|
+
&& (current.toolPrefix ?? `mcpl--${id}`) === prefix
|
|
13613
|
+
&& (this.mcplListedServers?.has(id) ?? false),
|
|
13614
|
+
};
|
|
13615
|
+
});
|
|
13616
|
+
const names = this.modelFacingToolNames();
|
|
13617
|
+
for (const entry of pending) {
|
|
13618
|
+
const verdict = checkToolPattern(entry.pattern, names, servers);
|
|
13619
|
+
if (verdict.kind !== 'unmatched')
|
|
13620
|
+
continue;
|
|
13621
|
+
reported.add(keyOf(entry));
|
|
13622
|
+
const where = entry.serverId ? `${entry.serverId}: ${entry.setting}` : entry.setting;
|
|
13623
|
+
console.error(describeUnmatchedPattern(where, entry.pattern, verdict));
|
|
13624
|
+
this.emitTrace({
|
|
13625
|
+
type: 'mcpl:tool-pattern-unmatched',
|
|
13626
|
+
setting: entry.setting,
|
|
13627
|
+
...(entry.serverId ? { serverId: entry.serverId } : {}),
|
|
13628
|
+
pattern: entry.pattern,
|
|
13629
|
+
...(verdict.suggestion ? { suggestion: verdict.suggestion } : {}),
|
|
13630
|
+
hint: verdict.hint,
|
|
13631
|
+
});
|
|
13632
|
+
}
|
|
13633
|
+
}
|
|
13634
|
+
catch (error) {
|
|
13635
|
+
console.error('[mcpl] tool pattern check failed:', error instanceof Error ? error.message : error);
|
|
13636
|
+
}
|
|
13637
|
+
}
|
|
13638
|
+
/** Every tool name the framework offers to anyone: the shared board plus
|
|
13639
|
+
* each agent's own surface, before presentation visibility (a hidden tool
|
|
13640
|
+
* is still callable, so classes and lifecycle still apply to it). */
|
|
13641
|
+
modelFacingToolNames() {
|
|
13642
|
+
const names = new Set(this.getAllTools().map((t) => t.name));
|
|
13643
|
+
for (const agent of this.agents.values()) {
|
|
13644
|
+
for (const tool of this.availableToolsForPresentation(agent.name))
|
|
13645
|
+
names.add(tool.name);
|
|
13646
|
+
}
|
|
13647
|
+
return names;
|
|
12443
13648
|
}
|
|
12444
13649
|
/**
|
|
12445
13650
|
* Handle a tools/list_changed notification with collapse logic.
|
|
@@ -12502,6 +13707,7 @@ export class AgentFramework {
|
|
|
12502
13707
|
this.pushEvent({
|
|
12503
13708
|
type: 'tool-result',
|
|
12504
13709
|
callId: call.id,
|
|
13710
|
+
toolName: call.name,
|
|
12505
13711
|
agentName,
|
|
12506
13712
|
moduleName: `mcpl:${serverId}`,
|
|
12507
13713
|
result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
|
|
@@ -12515,6 +13721,7 @@ export class AgentFramework {
|
|
|
12515
13721
|
this.pushEvent({
|
|
12516
13722
|
type: 'tool-result',
|
|
12517
13723
|
callId: call.id,
|
|
13724
|
+
toolName: call.name,
|
|
12518
13725
|
agentName,
|
|
12519
13726
|
moduleName: `mcpl:${serverId}`,
|
|
12520
13727
|
result: {
|
|
@@ -12652,6 +13859,7 @@ export class AgentFramework {
|
|
|
12652
13859
|
this.pushEvent({
|
|
12653
13860
|
type: 'tool-result',
|
|
12654
13861
|
callId: call.id,
|
|
13862
|
+
toolName: call.name,
|
|
12655
13863
|
agentName,
|
|
12656
13864
|
moduleName: `mcpl:${serverId}`,
|
|
12657
13865
|
result: {
|
|
@@ -12670,6 +13878,7 @@ export class AgentFramework {
|
|
|
12670
13878
|
this.pushEvent({
|
|
12671
13879
|
type: 'tool-result',
|
|
12672
13880
|
callId: call.id,
|
|
13881
|
+
toolName: call.name,
|
|
12673
13882
|
agentName,
|
|
12674
13883
|
moduleName: `mcpl:${serverId}`,
|
|
12675
13884
|
result: { success: false, error: err.message, isError: true },
|
|
@@ -12700,6 +13909,7 @@ export class AgentFramework {
|
|
|
12700
13909
|
this.pushEvent({
|
|
12701
13910
|
type: 'tool-result',
|
|
12702
13911
|
callId: call.id,
|
|
13912
|
+
toolName: call.name,
|
|
12703
13913
|
agentName,
|
|
12704
13914
|
moduleName: 'channels',
|
|
12705
13915
|
result: { success: false, error, isError: true },
|
|
@@ -12763,6 +13973,7 @@ export class AgentFramework {
|
|
|
12763
13973
|
this.pushEvent({
|
|
12764
13974
|
type: 'tool-result',
|
|
12765
13975
|
callId: call.id,
|
|
13976
|
+
toolName: call.name,
|
|
12766
13977
|
agentName,
|
|
12767
13978
|
moduleName: 'channels',
|
|
12768
13979
|
result,
|
|
@@ -12775,6 +13986,7 @@ export class AgentFramework {
|
|
|
12775
13986
|
this.pushEvent({
|
|
12776
13987
|
type: 'tool-result',
|
|
12777
13988
|
callId: call.id,
|
|
13989
|
+
toolName: call.name,
|
|
12778
13990
|
agentName,
|
|
12779
13991
|
moduleName: 'channels',
|
|
12780
13992
|
result: { success: false, error: err.message, isError: true },
|
|
@@ -12795,7 +14007,7 @@ export class AgentFramework {
|
|
|
12795
14007
|
module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
|
|
12796
14008
|
...(result.isError ? { error: result.error } : {}),
|
|
12797
14009
|
});
|
|
12798
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
14010
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
|
|
12799
14011
|
};
|
|
12800
14012
|
try {
|
|
12801
14013
|
if (call.name === 'wake_remove_rule') {
|
|
@@ -12953,9 +14165,10 @@ export class AgentFramework {
|
|
|
12953
14165
|
}
|
|
12954
14166
|
: { success: false, error: r.error, isError: false };
|
|
12955
14167
|
}
|
|
12956
|
-
this.
|
|
14168
|
+
this.pushEvent({
|
|
12957
14169
|
type: 'tool-result',
|
|
12958
14170
|
callId: call.id,
|
|
14171
|
+
toolName: call.name,
|
|
12959
14172
|
agentName,
|
|
12960
14173
|
moduleName: 'tune-out',
|
|
12961
14174
|
result,
|
|
@@ -12967,7 +14180,7 @@ export class AgentFramework {
|
|
|
12967
14180
|
const input = (call.input ?? {});
|
|
12968
14181
|
const finish = (result) => {
|
|
12969
14182
|
this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
|
|
12970
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
14183
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
|
|
12971
14184
|
};
|
|
12972
14185
|
if (call.name === 'wake') {
|
|
12973
14186
|
const was = gate.clearSleep();
|
|
@@ -12997,7 +14210,7 @@ export class AgentFramework {
|
|
|
12997
14210
|
else if (agent?.proseRouting === 'explicit') {
|
|
12998
14211
|
// Explicit mode: announce to the turn's sticky prose target if the
|
|
12999
14212
|
// model has set one; otherwise stay quiet — never guess a channel.
|
|
13000
|
-
const target = this.
|
|
14213
|
+
const target = this.turnRouting.get(agentName)?.pin;
|
|
13001
14214
|
if (target) {
|
|
13002
14215
|
this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
|
|
13003
14216
|
console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
|
|
@@ -13067,7 +14280,7 @@ export class AgentFramework {
|
|
|
13067
14280
|
...(result.isError ? { error: result.error } : {}),
|
|
13068
14281
|
});
|
|
13069
14282
|
this.pushEvent({
|
|
13070
|
-
type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
|
|
14283
|
+
type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
|
|
13071
14284
|
result: { ...result, isError: result.isError ?? false },
|
|
13072
14285
|
});
|
|
13073
14286
|
})();
|
|
@@ -13083,7 +14296,7 @@ export class AgentFramework {
|
|
|
13083
14296
|
durationMs: 0,
|
|
13084
14297
|
...(result.isError ? { error: result.error } : {}),
|
|
13085
14298
|
});
|
|
13086
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
14299
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
|
|
13087
14300
|
};
|
|
13088
14301
|
void (async () => {
|
|
13089
14302
|
try {
|
|
@@ -13106,7 +14319,11 @@ export class AgentFramework {
|
|
|
13106
14319
|
if (typeof workspace.handleToolCall === 'function' &&
|
|
13107
14320
|
typeof workspace.getTools === 'function' &&
|
|
13108
14321
|
workspace.getTools().some((tool) => tool.name === 'read_image')) {
|
|
13109
|
-
|
|
14322
|
+
// The workspace module's own handler, called directly: its result
|
|
14323
|
+
// gets the same check a module's result gets in the registry. The
|
|
14324
|
+
// agent's name is for the check's log line, not the handler.
|
|
14325
|
+
const moduleCall = { ...call, name: 'read_image' };
|
|
14326
|
+
const result = storableToolResult({ ...moduleCall, callerAgentName: agentName }, await workspace.handleToolCall(moduleCall));
|
|
13110
14327
|
if (result.success && Array.isArray(result.data)) {
|
|
13111
14328
|
const image = result.data.find((block) => typeof block === 'object' && block !== null &&
|
|
13112
14329
|
block.type === 'image' &&
|
|
@@ -13144,11 +14361,7 @@ export class AgentFramework {
|
|
|
13144
14361
|
});
|
|
13145
14362
|
}
|
|
13146
14363
|
catch (error) {
|
|
13147
|
-
finish(
|
|
13148
|
-
success: false,
|
|
13149
|
-
error: error instanceof Error ? error.message : String(error),
|
|
13150
|
-
isError: true,
|
|
13151
|
-
});
|
|
14364
|
+
finish(thrownResult(error));
|
|
13152
14365
|
}
|
|
13153
14366
|
})();
|
|
13154
14367
|
}
|
|
@@ -13212,7 +14425,7 @@ export class AgentFramework {
|
|
|
13212
14425
|
durationMs: 0,
|
|
13213
14426
|
...(result.isError ? { error: result.error } : {}),
|
|
13214
14427
|
});
|
|
13215
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
14428
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
|
|
13216
14429
|
};
|
|
13217
14430
|
void (async () => {
|
|
13218
14431
|
try {
|
|
@@ -13453,20 +14666,35 @@ export class AgentFramework {
|
|
|
13453
14666
|
for (const image of found) {
|
|
13454
14667
|
const destination = pathFor(image.rangeOffset);
|
|
13455
14668
|
const bytes = Buffer.from(image.data, 'base64');
|
|
13456
|
-
const
|
|
13457
|
-
|
|
13458
|
-
|
|
13459
|
-
|
|
13460
|
-
|
|
13461
|
-
|
|
13462
|
-
|
|
13463
|
-
|
|
13464
|
-
|
|
13465
|
-
|
|
14669
|
+
const failed = (why) => finish({
|
|
14670
|
+
success: false,
|
|
14671
|
+
error: `save_recent_image: failed writing "${destination}": ${why instanceof Error ? why.message : String(why)}` +
|
|
14672
|
+
(savedFiles.length > 0
|
|
14673
|
+
? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
|
|
14674
|
+
: ''),
|
|
14675
|
+
isError: true,
|
|
14676
|
+
});
|
|
14677
|
+
let result;
|
|
14678
|
+
try {
|
|
14679
|
+
result = await workspace.writeBinary(destination, bytes, image.mediaType);
|
|
14680
|
+
}
|
|
14681
|
+
catch (error) {
|
|
14682
|
+
// Thrown before its tree entry was set; the images before it
|
|
14683
|
+
// are saved, and the failure says so, as a returned one does.
|
|
14684
|
+
failed(error);
|
|
14685
|
+
return;
|
|
14686
|
+
}
|
|
14687
|
+
const inChronicle = result.data?.inChronicle === true;
|
|
14688
|
+
if (!result.success && !inChronicle) {
|
|
14689
|
+
failed(result.error);
|
|
13466
14690
|
return;
|
|
13467
14691
|
}
|
|
13468
14692
|
savedFiles.push({
|
|
13469
14693
|
...result.data,
|
|
14694
|
+
path: destination,
|
|
14695
|
+
size: bytes.byteLength,
|
|
14696
|
+
mimeType: image.mediaType,
|
|
14697
|
+
...(!result.success ? { diskCopyError: result.error ?? 'disk copy failed' } : {}),
|
|
13470
14698
|
imageIndex: index + image.rangeOffset,
|
|
13471
14699
|
...image.receipt,
|
|
13472
14700
|
});
|
|
@@ -13494,7 +14722,7 @@ export class AgentFramework {
|
|
|
13494
14722
|
* (Module.getUtilities). Shared by the model-facing dispatcher and the
|
|
13495
14723
|
* programmatic path (executeToolCall), so code_execution scripts and
|
|
13496
14724
|
* ephemerals reach utilities too. */
|
|
13497
|
-
async handleUtilsToolCall(agentName, call) {
|
|
14725
|
+
async handleUtilsToolCall(agentName, call, options = {}) {
|
|
13498
14726
|
const input = (call.input ?? {});
|
|
13499
14727
|
const utilities = this.moduleRegistry.getAllUtilities();
|
|
13500
14728
|
const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
|
|
@@ -13528,13 +14756,12 @@ export class AgentFramework {
|
|
|
13528
14756
|
};
|
|
13529
14757
|
}
|
|
13530
14758
|
// Same dispatch as a first-class tool call — the module cannot tell
|
|
13531
|
-
// which surface the call came through.
|
|
13532
|
-
|
|
13533
|
-
|
|
13534
|
-
|
|
13535
|
-
|
|
13536
|
-
|
|
13537
|
-
});
|
|
14759
|
+
// which surface the call came through. An in-process caller gets the
|
|
14760
|
+
// module's own result; the model's path takes its checked form.
|
|
14761
|
+
const utilityCall = { id: call.id, name: def.name, input: args, callerAgentName: agentName };
|
|
14762
|
+
return options.inProcess
|
|
14763
|
+
? this.moduleRegistry.handleToolCall(utilityCall)
|
|
14764
|
+
: this.moduleRegistry.storableToolCall(utilityCall);
|
|
13538
14765
|
}
|
|
13539
14766
|
default:
|
|
13540
14767
|
return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
|
|
@@ -13559,6 +14786,7 @@ export class AgentFramework {
|
|
|
13559
14786
|
this.pushEvent({
|
|
13560
14787
|
type: 'tool-result',
|
|
13561
14788
|
callId: call.id,
|
|
14789
|
+
toolName: call.name,
|
|
13562
14790
|
agentName,
|
|
13563
14791
|
moduleName: 'framework',
|
|
13564
14792
|
result,
|
|
@@ -13729,6 +14957,7 @@ export class AgentFramework {
|
|
|
13729
14957
|
this.pushEvent({
|
|
13730
14958
|
type: 'tool-result',
|
|
13731
14959
|
callId: call.id,
|
|
14960
|
+
toolName: call.name,
|
|
13732
14961
|
agentName,
|
|
13733
14962
|
moduleName: 'agent',
|
|
13734
14963
|
result,
|
|
@@ -13769,7 +14998,7 @@ export class AgentFramework {
|
|
|
13769
14998
|
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
13770
14999
|
result = { success: false, error: err.message, isError: true };
|
|
13771
15000
|
}
|
|
13772
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
15001
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
|
|
13773
15002
|
}
|
|
13774
15003
|
dispatchGateToolCall(agentName, call) {
|
|
13775
15004
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
@@ -13781,6 +15010,7 @@ export class AgentFramework {
|
|
|
13781
15010
|
this.pushEvent({
|
|
13782
15011
|
type: 'tool-result',
|
|
13783
15012
|
callId: call.id,
|
|
15013
|
+
toolName: call.name,
|
|
13784
15014
|
agentName,
|
|
13785
15015
|
moduleName: 'gate',
|
|
13786
15016
|
result,
|
|
@@ -13793,6 +15023,7 @@ export class AgentFramework {
|
|
|
13793
15023
|
this.pushEvent({
|
|
13794
15024
|
type: 'tool-result',
|
|
13795
15025
|
callId: call.id,
|
|
15026
|
+
toolName: call.name,
|
|
13796
15027
|
agentName,
|
|
13797
15028
|
moduleName: 'gate',
|
|
13798
15029
|
result: { success: false, error: err.message, isError: true },
|