@animalabs/agent-framework 0.21.0 → 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 +3 -3
- 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/src/framework.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } 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';
|
|
@@ -7,6 +8,7 @@ import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolRes
|
|
|
7
8
|
import { MembraneError } from '@animalabs/membrane';
|
|
8
9
|
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
|
|
9
10
|
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
11
|
+
import type { SilentHeartbeatStamp } from './silent-heartbeat.js';
|
|
10
12
|
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
|
|
11
13
|
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
12
14
|
import type {
|
|
@@ -59,32 +61,37 @@ import type {
|
|
|
59
61
|
SameRoundThinkTextPolicy,
|
|
60
62
|
} from './types/index.js';
|
|
61
63
|
import { ProcessQueueImpl } from './queue.js';
|
|
64
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
|
|
62
65
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
63
66
|
import { Agent } from './agent.js';
|
|
64
|
-
import { ModuleRegistry, isStateExistsError } from './module-registry.js';
|
|
67
|
+
import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
|
|
65
68
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
66
69
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
67
70
|
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
68
71
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
69
72
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
70
|
-
import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
|
|
73
|
+
import { ToolLifecycleEmitter, parseToolObserveParams, type ScriptCallOrigin } from './mcpl/tool-lifecycle.js';
|
|
74
|
+
import { checkToolPattern, describeUnmatchedPattern, type PatternServer } from './mcpl/tool-pattern-check.js';
|
|
71
75
|
import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
|
|
72
76
|
import { PushHandler, convertBlock as convertPushBlock, type McplPushEvent } from './mcpl/push-handler.js';
|
|
73
77
|
import {
|
|
74
78
|
PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
|
|
75
|
-
coalescingSubjectKey, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
|
|
79
|
+
coalescingSubjectKey, type AssemblyResult, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
|
|
76
80
|
type CoalescingReceiptRecord,
|
|
77
81
|
} from './mcpl/push-coalescer.js';
|
|
78
82
|
import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js';
|
|
83
|
+
import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './mcpl/visible-content.js';
|
|
79
84
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
80
85
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
81
86
|
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
82
87
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
83
88
|
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
84
|
-
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
89
|
+
import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
|
|
85
90
|
import { safeSlice } from './safe-slice.js';
|
|
86
91
|
import type { WorkspaceModule } from './modules/workspace/index.js';
|
|
92
|
+
import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
|
|
87
93
|
import {
|
|
94
|
+
nonErrorResultText,
|
|
88
95
|
toolResultDataToHistoryString,
|
|
89
96
|
truncateForHistory,
|
|
90
97
|
DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
|
|
@@ -476,6 +483,97 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string):
|
|
|
476
483
|
return { ...(metadata ?? {}), deferredWriteId: id } as MessageMetadata;
|
|
477
484
|
}
|
|
478
485
|
|
|
486
|
+
/**
|
|
487
|
+
* A prose block's envelopes, as explicit mode routes them: every line whose
|
|
488
|
+
* text, after its leading whitespace, is `>>` and then a non-space opens a
|
|
489
|
+
* new envelope (first live use: Tilde 2026-07-24, one block carrying a
|
|
490
|
+
* letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
|
|
491
|
+
* parser delivered the aside literally into the first destination — exactly
|
|
492
|
+
* the leak class this mode exists to kill). Text before the first `>>` is
|
|
493
|
+
* its own unprefixed envelope. A quoted `>> arrow` (space after) stays body
|
|
494
|
+
* text. Blank envelopes are dropped, and an envelope's whitespace before its
|
|
495
|
+
* `>>` is trimmed.
|
|
496
|
+
*/
|
|
497
|
+
function splitExplicitEnvelopes(rawText: string): string[] {
|
|
498
|
+
const envelopes: string[] = [];
|
|
499
|
+
let current: string[] = [];
|
|
500
|
+
for (const line of rawText.split('\n')) {
|
|
501
|
+
if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
|
|
502
|
+
envelopes.push(current.join('\n'));
|
|
503
|
+
current = [];
|
|
504
|
+
}
|
|
505
|
+
current.push(line);
|
|
506
|
+
}
|
|
507
|
+
if (current.length > 0) envelopes.push(current.join('\n'));
|
|
508
|
+
return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, ''));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* An envelope of explicit prose as its round was admitted: for a `>>` target,
|
|
513
|
+
* the registry's answer then, which its delivery uses.
|
|
514
|
+
*/
|
|
515
|
+
interface AdmittedEnvelope {
|
|
516
|
+
text: string;
|
|
517
|
+
resolved?: { channelId: string } | { error: string; candidates?: string[] };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Whether a stream resumes its logical turn rather than starting one: a budget
|
|
522
|
+
* restart, or a tool-result-guard retry. Such a stream keeps the turn's
|
|
523
|
+
* routing state (the sticky destination, the turn's destination) and queues
|
|
524
|
+
* its deliveries behind the turn's earlier ones.
|
|
525
|
+
*/
|
|
526
|
+
function resumesLogicalTurn(reason: string | undefined): boolean {
|
|
527
|
+
return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* One turn's routing and delivery receipts (AgentFramework.turnRouting).
|
|
532
|
+
* A fresh turn gets a new object; a queued delivery keeps its own turn's.
|
|
533
|
+
*/
|
|
534
|
+
interface TurnRouting {
|
|
535
|
+
/** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */
|
|
536
|
+
pin?: string;
|
|
537
|
+
/**
|
|
538
|
+
* Explicit mode: the destination the turn's delivered rounds have set,
|
|
539
|
+
* decided at each round boundary (roundDestination), where a stream that
|
|
540
|
+
* resumes the turn starts.
|
|
541
|
+
*/
|
|
542
|
+
destination: string | null;
|
|
543
|
+
/** Hybrid: held back after a malformed or unresolved envelope, until a later `>>>` names a target. */
|
|
544
|
+
hybridHold: boolean;
|
|
545
|
+
/** What the logical turn keeps across a new stream (LogicalTurn). */
|
|
546
|
+
logical: LogicalTurn;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* What every stream of one logical turn shares, from the fresh start that
|
|
551
|
+
* opens it until it settles (AgentFramework.openLogical): a framework retry,
|
|
552
|
+
* a re-inference after unstreamed tool results, a capacity-held turn's later
|
|
553
|
+
* compile. beginAgentTurn starts each with fresh routing, but a ` !` asked
|
|
554
|
+
* for in any of them stands, and the receipt at the turn's end reports every
|
|
555
|
+
* stream's deliveries, even ones that land after a new stream began.
|
|
556
|
+
*/
|
|
557
|
+
interface LogicalTurn {
|
|
558
|
+
/** The turn asked for a ` !` continuation; taken once. */
|
|
559
|
+
continuation: boolean;
|
|
560
|
+
/** Successful plain-prose destinations in delivery order; deduped by the receipt. */
|
|
561
|
+
deliveries: string[];
|
|
562
|
+
/** Plain-prose segments the turn suppressed, reported by its receipt. */
|
|
563
|
+
suppressed: number;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function freshTurnRouting(): TurnRouting {
|
|
567
|
+
return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** How a round's prose is delivered: kept private, and under which turn's routing. */
|
|
571
|
+
interface ProseRound {
|
|
572
|
+
keepPrivate?: boolean;
|
|
573
|
+
/** The routing state of the turn that queued the round. */
|
|
574
|
+
routing: TurnRouting;
|
|
575
|
+
}
|
|
576
|
+
|
|
479
577
|
function isTurnContinuation(reason: string): boolean {
|
|
480
578
|
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
481
579
|
|| reason === 'tool_result_guard_retry';
|
|
@@ -765,6 +863,10 @@ class DefaultErrorPolicy implements ErrorPolicy {
|
|
|
765
863
|
|
|
766
864
|
/** Default sync interval in milliseconds */
|
|
767
865
|
const DEFAULT_SYNC_INTERVAL_MS = 1000;
|
|
866
|
+
/** Total time one withheld batch may spend awaiting workspace writes.
|
|
867
|
+
* At the deadline its unfinished write is named as such, and later
|
|
868
|
+
* originals stay in the audit. This bound applies to withheld spills. */
|
|
869
|
+
const WITHHELD_SPILL_DEADLINE_MS = 30_000;
|
|
768
870
|
/** Poll pending context-strategy maintenance independently of user activity. */
|
|
769
871
|
const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
|
|
770
872
|
/** Bound one pass so a large backlog yields to inference and other agents. */
|
|
@@ -948,9 +1050,100 @@ function truncateReason(reason: string, max = 160): string {
|
|
|
948
1050
|
return reason.length <= max ? reason : reason.slice(0, max) + '…';
|
|
949
1051
|
}
|
|
950
1052
|
|
|
1053
|
+
/** The most of a thrown value's or a tool error's text that reaches a log line or a server's error answer. */
|
|
1054
|
+
const THROWN_TEXT_LIMIT = 500;
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* What a thrown value says, safely and briefly: an Error's message, or any
|
|
1058
|
+
* other value, as a string. Every inspection and coercion of the value is
|
|
1059
|
+
* inside the try, so reading it can't throw: a revoked Proxy (whose
|
|
1060
|
+
* instanceof throws), a hostile getter, an Error whose message isn't a
|
|
1061
|
+
* string, or a value with no string form reads as text or as unreadable.
|
|
1062
|
+
* The text is bounded, since a provider or handler error can carry a whole
|
|
1063
|
+
* request body.
|
|
1064
|
+
*/
|
|
1065
|
+
function thrownText(error: unknown): string {
|
|
1066
|
+
let text: string;
|
|
1067
|
+
try {
|
|
1068
|
+
text = String(error instanceof Error ? error.message : error);
|
|
1069
|
+
} catch {
|
|
1070
|
+
text = '(a value that could not be read)';
|
|
1071
|
+
}
|
|
1072
|
+
return text.length <= THROWN_TEXT_LIMIT ? text : `${text.slice(0, THROWN_TEXT_LIMIT)}… (${text.length} characters)`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* A failed handler's reason, never throwing: an Error's message, else "the
|
|
1077
|
+
* handler failed" and what it threw (nothing, for `undefined`). Telling an
|
|
1078
|
+
* Error apart is itself guarded, since instanceof throws on a revoked Proxy.
|
|
1079
|
+
*/
|
|
1080
|
+
function handlerFailureText(error: unknown): string {
|
|
1081
|
+
let isError = false;
|
|
1082
|
+
try {
|
|
1083
|
+
isError = error instanceof Error;
|
|
1084
|
+
} catch {
|
|
1085
|
+
// Not an Error that can be read; thrownText says so.
|
|
1086
|
+
}
|
|
1087
|
+
if (isError) return thrownText(error);
|
|
1088
|
+
return error === undefined ? 'the handler failed' : `the handler failed: ${thrownText(error)}`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** A server's responder, and whether the request it belongs to has been answered yet. */
|
|
1092
|
+
type AnswerOnce = {
|
|
1093
|
+
responder: { respond?: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void } | undefined;
|
|
1094
|
+
answered: () => boolean;
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Wrap a server request's responder so the request is answered at most once,
|
|
1099
|
+
* and a later failure can tell whether the handler already answered.
|
|
1100
|
+
* - An answer counts once the responder returns. One that throws, such as a
|
|
1101
|
+
* result the transport can't serialize, leaves the request unanswered, so
|
|
1102
|
+
* the listener's failure answer still goes out. This assumes a responder
|
|
1103
|
+
* that throws has sent nothing, which holds for the connection's own: they
|
|
1104
|
+
* serialize before they write, and their transports' writes don't throw
|
|
1105
|
+
* after writing.
|
|
1106
|
+
* - A second answer is refused and logged: a handler that answered and then
|
|
1107
|
+
* threw isn't answered again, and a handler that answers after the
|
|
1108
|
+
* listener answered its failure doesn't send a second response.
|
|
1109
|
+
* - Only the methods the responder has are wrapped, so a handler that checks
|
|
1110
|
+
* for `respondError` still finds a responder without one.
|
|
1111
|
+
*/
|
|
1112
|
+
function answerOnce<R extends { respond?: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void }>(
|
|
1113
|
+
responder: R | undefined,
|
|
1114
|
+
method: string,
|
|
1115
|
+
serverId: string,
|
|
1116
|
+
): AnswerOnce & { responder: R | undefined } {
|
|
1117
|
+
if (!responder) return { responder: undefined, answered: () => false };
|
|
1118
|
+
let done = false;
|
|
1119
|
+
const once = <A extends unknown[]>(kind: string, send: (...args: A) => void) => (...args: A): void => {
|
|
1120
|
+
if (done) {
|
|
1121
|
+
console.error(`[mcpl] ${method} from ${serverId}: a second answer (${kind}) was refused; the request was already answered`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
send(...args);
|
|
1125
|
+
done = true;
|
|
1126
|
+
};
|
|
1127
|
+
const wrapped: { respond?: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void } = { ...responder };
|
|
1128
|
+
if (responder.respond) wrapped.respond = once('respond', (result: unknown) => responder.respond!(result));
|
|
1129
|
+
if (responder.respondError) {
|
|
1130
|
+
wrapped.respondError = once('respondError', (code: number, message: string, data?: unknown) => responder.respondError!(code, message, data));
|
|
1131
|
+
}
|
|
1132
|
+
return { responder: wrapped as R, answered: () => done };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
951
1135
|
export class AgentFramework {
|
|
1136
|
+
private toolPresentations = new Map<string, ToolPresentation>();
|
|
1137
|
+
private presentationPreviews = new WeakMap<object, PresentationSnapshot>();
|
|
1138
|
+
getRequestToolPresentation(request: object): PresentationSnapshot | null {
|
|
1139
|
+
return this.presentationPreviews.get(request) ?? null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
952
1142
|
private store: JsStore;
|
|
953
1143
|
private ownsStore: boolean;
|
|
1144
|
+
private stopPromise: Promise<void> | null = null;
|
|
1145
|
+
private stopCompleted = false;
|
|
1146
|
+
private ownedStoreClosed = false;
|
|
954
1147
|
private membrane: Membrane;
|
|
955
1148
|
private queue: ProcessQueueImpl;
|
|
956
1149
|
private agents: Map<string, Agent> = new Map();
|
|
@@ -998,6 +1191,8 @@ export class AgentFramework {
|
|
|
998
1191
|
private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
|
|
999
1192
|
private providerHoldHook: ProviderHoldHook | undefined;
|
|
1000
1193
|
private providerAdmissionClosed = false;
|
|
1194
|
+
/** Invalidates framework turns still assembling when stop begins. */
|
|
1195
|
+
private lifecycleGeneration = 0;
|
|
1001
1196
|
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
1002
1197
|
private staleWarnAt = new Map<string, number>();
|
|
1003
1198
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
@@ -1032,26 +1227,6 @@ export class AgentFramework {
|
|
|
1032
1227
|
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
1033
1228
|
* turn's start; budget restarts keep it. */
|
|
1034
1229
|
private turnEngagedChannels: Map<string, Set<string>> = new Map();
|
|
1035
|
-
/** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
|
|
1036
|
-
* order (deduped at render). Feeds the `[delivered]` receipt appended at
|
|
1037
|
-
* logical turn end: explicit sends receipt themselves via their
|
|
1038
|
-
* tool_result, but auto-routed prose previously vanished into the router
|
|
1039
|
-
* with no in-window record — the agent could never see where its own
|
|
1040
|
-
* words landed (2026-07-31 misroute series; antra: minimal receipts).
|
|
1041
|
-
* Cleared at every fresh turn's start; budget restarts keep it (same
|
|
1042
|
-
* logical turn, receipt covers the whole turn). */
|
|
1043
|
-
private turnProseDeliveries: Map<string, string[]> = new Map();
|
|
1044
|
-
/** Count of plain-prose segments SUPPRESSED this turn by sticky
|
|
1045
|
-
* explicit-send silencing. The suppression rule itself is untouched
|
|
1046
|
-
* (antra, 2026-07-31: visibility is enough) — but it must never be a
|
|
1047
|
-
* silent black hole: Mythos multiplexed two threads in one round (prose
|
|
1048
|
-
* for the lane + explicit send for another channel, textbook per the
|
|
1049
|
-
* routing doc), the silencing ate the prose reply, and his own record
|
|
1050
|
-
* ("scene delivered to #mythos via plain speech — routing confirmed")
|
|
1051
|
-
* believed it delivered. The receipt now reports suppression, so the
|
|
1052
|
-
* author sees the segment's fate one turn later. Cleared each fresh
|
|
1053
|
-
* turn; budget restarts keep it. */
|
|
1054
|
-
private turnProseSuppressed: Map<string, number> = new Map();
|
|
1055
1230
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
1056
1231
|
* not a reaction or a system marker) into the live stream. Tells
|
|
1057
1232
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -1066,14 +1241,43 @@ export class AgentFramework {
|
|
|
1066
1241
|
// ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
|
|
1067
1242
|
/** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
|
|
1068
1243
|
private proseClipboards: Map<string, string> = new Map();
|
|
1069
|
-
/**
|
|
1070
|
-
*
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
*
|
|
1076
|
-
|
|
1244
|
+
/**
|
|
1245
|
+
* Each agent's routing state for its current turn (TurnRouting): the
|
|
1246
|
+
* sticky destination, the turn's destination for a resumed stream,
|
|
1247
|
+
* hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object
|
|
1248
|
+
* instead of clearing the old one, and a stream that resumes the turn
|
|
1249
|
+
* (resumesLogicalTurn) keeps it. A queued delivery holds the object of the
|
|
1250
|
+
* turn that queued it, so prose a turn leaves queued when it ends (a
|
|
1251
|
+
* skip_reply ends it before its posts have run) still follows that turn's
|
|
1252
|
+
* destination, and its late writes can't reach the next turn.
|
|
1253
|
+
*/
|
|
1254
|
+
private turnRouting: Map<string, TurnRouting> = new Map();
|
|
1255
|
+
/**
|
|
1256
|
+
* The latest link of each agent's chain of queued prose deliveries. Every
|
|
1257
|
+
* stream queues its own behind it, a fresh turn's included, so the agent's
|
|
1258
|
+
* posts go out in the order they were written, across streams and turns.
|
|
1259
|
+
*/
|
|
1260
|
+
private proseSpeechChains: Map<string, Promise<void>> = new Map();
|
|
1261
|
+
/**
|
|
1262
|
+
* Each agent's open logical turn (LogicalTurn). It stays open from the
|
|
1263
|
+
* fresh start that opens it until the turn settles (settleAgent: it
|
|
1264
|
+
* completes, a tool ends it, or it fails or is abandoned), and every fresh
|
|
1265
|
+
* start that routes prose while it is open continues it: a framework
|
|
1266
|
+
* retry, the start that consumes tool results the non-streaming fallback
|
|
1267
|
+
* left (whichever wake the scheduler starts it with), the later compile a
|
|
1268
|
+
* provider-capacity hold defers a turn to. A silent start neither
|
|
1269
|
+
* continues nor opens one. A stream that ends without settling needs to do
|
|
1270
|
+
* nothing to carry its turn. Settling only detaches the turn from future
|
|
1271
|
+
* starts: the settling stream keeps its own object, which its remaining
|
|
1272
|
+
* deliveries, its receipt and its ` !` check go on using (a completion
|
|
1273
|
+
* settles before it awaits its chain and writes its receipt). So every
|
|
1274
|
+
* logical end must close inheritance once, before the next fresh start:
|
|
1275
|
+
* a turn left open would hand its object, late writes and all, to an
|
|
1276
|
+
* unrelated turn. A tool-ended turn settles before the next turn can
|
|
1277
|
+
* start. Only the stream that holds the open turn closes it; disposal
|
|
1278
|
+
* drops it, and stop() clears every agent's.
|
|
1279
|
+
*/
|
|
1280
|
+
private openLogical: Map<string, LogicalTurn> = new Map();
|
|
1077
1281
|
/** Consecutive bounce-wakes per agent; capped so an agent that keeps
|
|
1078
1282
|
* emitting unprefixed prose can't wake-loop (notices still append). */
|
|
1079
1283
|
private proseBounceStreaks: Map<string, number> = new Map();
|
|
@@ -1308,10 +1512,16 @@ export class AgentFramework {
|
|
|
1308
1512
|
private codeExecutionConfig: import('./types/index.js').CodeExecutionConfig | null = null;
|
|
1309
1513
|
private codeExecutionRunners: Map<string, PyRunner> = new Map();
|
|
1310
1514
|
private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
|
|
1311
|
-
/**
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
|
|
1515
|
+
/** Per agent, the foreground script now running. An endTurn-carrying inner
|
|
1516
|
+
* result marks the script that made the call, and is applied to that
|
|
1517
|
+
* script's code_execution result instead of cancelling the stream
|
|
1518
|
+
* mid-script (which would wedge the turn). A stopped script's late result
|
|
1519
|
+
* marks only that script, never the agent's next one. */
|
|
1520
|
+
private scriptDeferredEndTurn: Map<string, { endTurn: boolean }> = new Map();
|
|
1521
|
+
/** RFC-007: per agent, the origin of the foreground script its runner is
|
|
1522
|
+
* executing, handed to that script's inner calls (scripts run one at a
|
|
1523
|
+
* time per runner). Background runners capture theirs in the closure. */
|
|
1524
|
+
private foregroundScriptOrigins: Map<string, ScriptCallOrigin> = new Map();
|
|
1315
1525
|
/** Background (daemon) scripts: model-authored watchers that outlive their
|
|
1316
1526
|
* spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
|
|
1317
1527
|
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
@@ -1343,6 +1553,16 @@ export class AgentFramework {
|
|
|
1343
1553
|
/** Namespaced tool name → the server whose tools/list produced it. Prefixes
|
|
1344
1554
|
* can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
|
|
1345
1555
|
private mcplToolServers: Map<string, string> = new Map();
|
|
1556
|
+
/** Servers whose tools/list answered in the latest refresh. */
|
|
1557
|
+
private mcplListedServers: Set<string> = new Set();
|
|
1558
|
+
/** Zero-match tool-pattern diagnostics (checkToolPatterns): set at the end
|
|
1559
|
+
* of create(), when the tool universe is first complete. */
|
|
1560
|
+
private toolPatternChecksArmed = false;
|
|
1561
|
+
/** Patterns already reported as matching nothing — each is reported once. */
|
|
1562
|
+
private toolPatternsReported: Set<string> = new Set();
|
|
1563
|
+
/** Every (prefix → serverId) ever configured. A removed or restarting
|
|
1564
|
+
* server's patterns stay undecided rather than reported mid-restart. */
|
|
1565
|
+
private toolPatternKnownPrefixes: Map<string, string> = new Map();
|
|
1346
1566
|
/** Maps serverId → McplServerConfig for prefix lookup. */
|
|
1347
1567
|
private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
|
|
1348
1568
|
/** Host capabilities advertised during the MCP handshake — stored so servers
|
|
@@ -1577,6 +1797,7 @@ export class AgentFramework {
|
|
|
1577
1797
|
// Create agents
|
|
1578
1798
|
for (const agentConfig of config.agents) {
|
|
1579
1799
|
await framework.createAgent(agentConfig);
|
|
1800
|
+
if (agentConfig.toolPresentation) framework.toolPresentations.set(agentConfig.name, new ToolPresentation(agentConfig.toolPresentation));
|
|
1580
1801
|
}
|
|
1581
1802
|
|
|
1582
1803
|
// The subconscious resident (issue #77) registers after the residents so
|
|
@@ -1594,6 +1815,17 @@ export class AgentFramework {
|
|
|
1594
1815
|
for (const module of config.modules) {
|
|
1595
1816
|
await framework.addModule(module);
|
|
1596
1817
|
}
|
|
1818
|
+
for (const [agentName, presentation] of framework.toolPresentations) {
|
|
1819
|
+
const workspace = framework.getWorkspaceModule();
|
|
1820
|
+
if (!workspace) throw new Error('Tool presentation requires workspace');
|
|
1821
|
+
const agent = framework.agents.get(agentName)!;
|
|
1822
|
+
for (const name of ['workspace--read', 'set_tool_visibility', 'set_tool_description']) {
|
|
1823
|
+
if (!agent.canUseTool(name)) throw new Error(`Tool presentation recovery requires ${name}`);
|
|
1824
|
+
}
|
|
1825
|
+
workspace.registerGeneratedTextFile(presentation.config.cataloguePath,
|
|
1826
|
+
() => renderCatalogue(framework.inspectToolPresentation(agentName)!), agentName);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1597
1829
|
|
|
1598
1830
|
// Initialize per-channel conversation routing (if configured)
|
|
1599
1831
|
if (config.conversations) {
|
|
@@ -1604,6 +1836,9 @@ export class AgentFramework {
|
|
|
1604
1836
|
);
|
|
1605
1837
|
}
|
|
1606
1838
|
framework.conversationRouter = new ConversationRouter(config.conversations);
|
|
1839
|
+
// Deprecated feature (agent-framework#235): still wired exactly as
|
|
1840
|
+
// before, but named once per framework instance on stderr.
|
|
1841
|
+
console.warn(`[deprecated] ${CONVERSATION_ROUTING_DEPRECATION_NOTICE}`);
|
|
1607
1842
|
|
|
1608
1843
|
// Generation counters persist across restarts — reusing generation 1's
|
|
1609
1844
|
// agent name after a restart would reopen (and re-seed) the previous
|
|
@@ -1640,16 +1875,41 @@ export class AgentFramework {
|
|
|
1640
1875
|
framework.pendingRequests.push({
|
|
1641
1876
|
agentName, reason, source, timestamp: Date.now(),
|
|
1642
1877
|
// gate-requested wakes carry where/who (EventGate wakeProvenance)
|
|
1643
|
-
// as TELEMETRY fields — the host's stamp reads them
|
|
1644
|
-
// speech locus (channelId / addressed) is deliberately NOT set
|
|
1645
|
-
// here, so a batched wake routes exactly as it did before.
|
|
1878
|
+
// as TELEMETRY fields — the host's stamp reads them.
|
|
1646
1879
|
...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
|
|
1647
1880
|
...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
|
|
1648
1881
|
...(provenance?.at ? { wakeAt: provenance.at } : {}),
|
|
1882
|
+
// A batch that contains an ADDRESSED message (mention / reply /
|
|
1883
|
+
// DM) routes like the direct path does: the reply goes to that
|
|
1884
|
+
// message's channel. Without it the turn froze on the
|
|
1885
|
+
// process-global most-recent-inbound channel, which an ambient
|
|
1886
|
+
// message elsewhere could retarget between the DM's arrival and
|
|
1887
|
+
// the debounce firing (2026-09-30). Ambient-only batches set no
|
|
1888
|
+
// locus and keep the legacy fallback.
|
|
1889
|
+
//
|
|
1890
|
+
// The wake is broadcast to every agent, conversation forks
|
|
1891
|
+
// included, and a fork speaks only in its home channel: the
|
|
1892
|
+
// route applies to a fork only when it IS that home, and never to
|
|
1893
|
+
// another agent when a fork owns that channel. Otherwise a fork
|
|
1894
|
+
// bound elsewhere would show typing where it isn't replying.
|
|
1895
|
+
...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
|
|
1896
|
+
? { channelId: provenance.routeChannelId, addressed: true }
|
|
1897
|
+
: {}),
|
|
1649
1898
|
});
|
|
1650
1899
|
},
|
|
1651
1900
|
getAgentNames: () => [...framework.agents.keys()].filter(
|
|
1652
1901
|
(n) => n !== framework.subconsciousAgentName),
|
|
1902
|
+
// Push events reach the gate with the adapter's raw channel id; map
|
|
1903
|
+
// them to the registered composite id the same way ingestion does
|
|
1904
|
+
// (handleMcplPushEvent registers that channel on arrival, before any
|
|
1905
|
+
// debounce can fire).
|
|
1906
|
+
resolveRouteChannel: (info) => {
|
|
1907
|
+
if (info.eventType === 'mcpl:channel-incoming') return info.channelId || undefined;
|
|
1908
|
+
if (info.eventType === 'mcpl:push-event') {
|
|
1909
|
+
return framework.derivePushEventChannel(info.metadata)?.channelId;
|
|
1910
|
+
}
|
|
1911
|
+
return undefined;
|
|
1912
|
+
},
|
|
1653
1913
|
});
|
|
1654
1914
|
}
|
|
1655
1915
|
|
|
@@ -1832,6 +2092,12 @@ export class AgentFramework {
|
|
|
1832
2092
|
// SIGUSR2 not available on this platform — non-fatal.
|
|
1833
2093
|
}
|
|
1834
2094
|
|
|
2095
|
+
// Modules, agents, MCPL servers and the tune-out tool are all in place:
|
|
2096
|
+
// the first point at which "matches no tool" can be judged. Later tool
|
|
2097
|
+
// refreshes re-check patterns still undecided.
|
|
2098
|
+
framework.toolPatternChecksArmed = true;
|
|
2099
|
+
framework.checkToolPatterns();
|
|
2100
|
+
|
|
1835
2101
|
return framework;
|
|
1836
2102
|
}
|
|
1837
2103
|
|
|
@@ -1843,8 +2109,14 @@ export class AgentFramework {
|
|
|
1843
2109
|
return;
|
|
1844
2110
|
}
|
|
1845
2111
|
|
|
2112
|
+
this.resetCompletedStop();
|
|
2113
|
+
|
|
1846
2114
|
this.running = true;
|
|
1847
2115
|
this.providerAdmissionClosed = false;
|
|
2116
|
+
// A new lifecycle: a turn compiled before an earlier stop() never drives,
|
|
2117
|
+
// and withheld spills, closed for the generation a stop ended, are open
|
|
2118
|
+
// for this one.
|
|
2119
|
+
this.lifecycleGeneration++;
|
|
1848
2120
|
this.loopPromise = this.runLoop();
|
|
1849
2121
|
|
|
1850
2122
|
// Start periodic sync timer (if enabled)
|
|
@@ -1870,9 +2142,36 @@ export class AgentFramework {
|
|
|
1870
2142
|
}
|
|
1871
2143
|
|
|
1872
2144
|
/**
|
|
1873
|
-
* Stop the event loop.
|
|
2145
|
+
* Stop the event loop. Failed module/MCPL cleanup can be retried with its
|
|
2146
|
+
* original context; storage stays open until that cleanup succeeds.
|
|
2147
|
+
* @param teardownTimeoutMs Optional module/MCPL wait budget. Omit to wait
|
|
2148
|
+
* until cleanup settles, as required by existing module/child contracts. This
|
|
2149
|
+
* does not bound earlier stream or event-loop quiescence, or cancel cleanup.
|
|
1874
2150
|
*/
|
|
1875
|
-
|
|
2151
|
+
stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
|
|
2152
|
+
try { validateShutdownTimeout(teardownTimeoutMs); }
|
|
2153
|
+
catch (error) { return Promise.reject(error); }
|
|
2154
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2155
|
+
const attempt = this.stopOwned(teardownTimeoutMs);
|
|
2156
|
+
this.stopPromise = attempt;
|
|
2157
|
+
void attempt.then(() => { this.stopCompleted = true; }, () => {});
|
|
2158
|
+
// Failed cleanup can be retried while its original store is open. A
|
|
2159
|
+
// terminal storage failure after successful close must remain a failure.
|
|
2160
|
+
void attempt.catch(() => {
|
|
2161
|
+
if (!this.ownedStoreClosed && this.stopPromise === attempt) this.stopPromise = null;
|
|
2162
|
+
});
|
|
2163
|
+
return attempt;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
private resetCompletedStop(): void {
|
|
2167
|
+
if (this.ownedStoreClosed) throw new Error('Cannot start a new lifecycle: owned store is closed');
|
|
2168
|
+
if (this.stopCompleted) {
|
|
2169
|
+
this.stopPromise = null;
|
|
2170
|
+
this.stopCompleted = false;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
private async stopOwned(teardownTimeoutMs: number | undefined): Promise<void> {
|
|
1876
2175
|
this.pushCoalescer?.suspend();
|
|
1877
2176
|
this.flushCoalescingSnapshot();
|
|
1878
2177
|
this.running = false;
|
|
@@ -1880,6 +2179,10 @@ export class AgentFramework {
|
|
|
1880
2179
|
// store is still open, rather than leaving them to a reboot replay.
|
|
1881
2180
|
this.ackDeferredWrites();
|
|
1882
2181
|
this.providerAdmissionClosed = true;
|
|
2182
|
+
this.lifecycleGeneration++;
|
|
2183
|
+
// The generation this stop ends; withheld spills close for it alone, so a
|
|
2184
|
+
// start() made before this stop finishes reopens them for its own.
|
|
2185
|
+
const stoppingGeneration = this.lifecycleGeneration;
|
|
1883
2186
|
this.queue.close();
|
|
1884
2187
|
this.tuneOutCoordinator?.stop();
|
|
1885
2188
|
|
|
@@ -1907,6 +2210,10 @@ export class AgentFramework {
|
|
|
1907
2210
|
|
|
1908
2211
|
// A stopped host must never hang behind its own cooldown.
|
|
1909
2212
|
this.cancelProviderAdmission();
|
|
2213
|
+
// An open logical turn doesn't survive a stop: a start after a restart
|
|
2214
|
+
// on this instance opens a new one rather than carrying its ` !` and
|
|
2215
|
+
// receipt tallies across (openLogical).
|
|
2216
|
+
this.openLogical.clear();
|
|
1910
2217
|
|
|
1911
2218
|
// Cancel all active streams
|
|
1912
2219
|
for (const agent of this.agents.values()) {
|
|
@@ -1946,20 +2253,46 @@ export class AgentFramework {
|
|
|
1946
2253
|
this.eventGate?.dispose();
|
|
1947
2254
|
this.livenessWatchdog?.stop();
|
|
1948
2255
|
|
|
2256
|
+
// A stopping stream's batch settles as withheld, and its stub names where
|
|
2257
|
+
// the original went: let those writes finish while the workspace is up.
|
|
2258
|
+
// Tracked here rather than per agent, since an ephemeral run or a closed
|
|
2259
|
+
// fork leaves the agent map while its annotation can still be running.
|
|
2260
|
+
while (this.guardAnnotations.size > 0) await Promise.all([...this.guardAnnotations]);
|
|
2261
|
+
// An annotation that starts after this point, such as one from a direct
|
|
2262
|
+
// Agent API call stop doesn't cancel, starts no write to the stopping
|
|
2263
|
+
// workspace: its stub names the audit record. A write that was past its
|
|
2264
|
+
// deadline isn't waited for here, but the workspace's own stop waits for
|
|
2265
|
+
// the mount turn it holds, bounded only by teardownTimeoutMs.
|
|
2266
|
+
this.guardSpillsClosedFor = stoppingGeneration;
|
|
2267
|
+
|
|
1949
2268
|
// Stop modules and MCPL servers in parallel
|
|
1950
|
-
const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll()];
|
|
2269
|
+
const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
|
|
1951
2270
|
if (this.mcplServerRegistry) {
|
|
1952
|
-
shutdownPromises.push(this.mcplServerRegistry.closeAll());
|
|
2271
|
+
shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
|
|
2272
|
+
}
|
|
2273
|
+
// Timed-out or failed teardown retains its context and may still write.
|
|
2274
|
+
// Sync available state best-effort, but leave its store open for a retry.
|
|
2275
|
+
const shutdownErrors: unknown[] = [];
|
|
2276
|
+
for (const result of await Promise.allSettled(shutdownPromises)) {
|
|
2277
|
+
if (result.status === 'rejected') shutdownErrors.push(result.reason);
|
|
1953
2278
|
}
|
|
1954
|
-
|
|
2279
|
+
|
|
2280
|
+
const teardownSucceeded = shutdownErrors.length === 0;
|
|
2281
|
+
|
|
2282
|
+
// Those later annotations write nothing, so they finish at once; let them
|
|
2283
|
+
// record and edit their stubs before the store closes.
|
|
2284
|
+
while (this.guardAnnotations.size > 0) await Promise.all([...this.guardAnnotations]);
|
|
1955
2285
|
|
|
1956
2286
|
// Streams may queue storage repairs while being cancelled above. Retry
|
|
1957
|
-
// after their teardown, while the store is still open, before final sync
|
|
1958
|
-
|
|
2287
|
+
// after their teardown, while the store is still open, before final sync:
|
|
2288
|
+
// every live agent's guard, and every other guard that isn't idle, whose
|
|
2289
|
+
// ephemeral run or closed fork has left the agent map.
|
|
2290
|
+
for (const [guard, name] of this.guardsWithStorageWork()) {
|
|
1959
2291
|
try {
|
|
1960
|
-
|
|
2292
|
+
guard.flushUnrecorded();
|
|
1961
2293
|
} catch (error) {
|
|
1962
|
-
console.error(`[tool-result-guard] agent=${
|
|
2294
|
+
console.error(`[tool-result-guard] agent=${name} could not finish queued storage work at stop:`, error);
|
|
2295
|
+
shutdownErrors.push(error);
|
|
1963
2296
|
}
|
|
1964
2297
|
}
|
|
1965
2298
|
|
|
@@ -1968,11 +2301,23 @@ export class AgentFramework {
|
|
|
1968
2301
|
this.store.sync();
|
|
1969
2302
|
} catch (error) {
|
|
1970
2303
|
console.error('Final sync error:', error);
|
|
2304
|
+
shutdownErrors.push(error);
|
|
1971
2305
|
}
|
|
1972
2306
|
|
|
1973
|
-
if (this.ownsStore) {
|
|
1974
|
-
|
|
2307
|
+
if (this.ownsStore && teardownSucceeded) {
|
|
2308
|
+
try {
|
|
2309
|
+
this.store.close();
|
|
2310
|
+
this.ownedStoreClosed = true;
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
// Chronicle may consume the handle before close's internal sync
|
|
2313
|
+
// throws. A consumed handle cannot retry the original operation.
|
|
2314
|
+
this.ownedStoreClosed = this.store.isClosed();
|
|
2315
|
+
shutdownErrors.push(error);
|
|
2316
|
+
}
|
|
1975
2317
|
}
|
|
2318
|
+
if (shutdownErrors.length === 1) throw shutdownErrors[0];
|
|
2319
|
+
if (shutdownErrors.length > 1) throw new AggregateError(shutdownErrors,
|
|
2320
|
+
`Framework shutdown failed: ${shutdownErrors.map(shutdownErrorMessage).join('; ')}`);
|
|
1976
2321
|
}
|
|
1977
2322
|
|
|
1978
2323
|
/**
|
|
@@ -2193,6 +2538,17 @@ export class AgentFramework {
|
|
|
2193
2538
|
}
|
|
2194
2539
|
|
|
2195
2540
|
private async runQueuedMaintenance(): Promise<void> {
|
|
2541
|
+
// A guard whose agent has left the map gets no activation to retry its
|
|
2542
|
+
// queue; retry it here. It leaves busyGuards once nothing of it is left.
|
|
2543
|
+
const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
|
|
2544
|
+
for (const [guard, name] of this.busyGuards) {
|
|
2545
|
+
if (live.has(guard)) continue;
|
|
2546
|
+
try {
|
|
2547
|
+
guard.flushUnrecorded();
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2196
2552
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
2197
2553
|
try {
|
|
2198
2554
|
agent.toolResultGuard.flushUnrecorded();
|
|
@@ -2200,7 +2556,7 @@ export class AgentFramework {
|
|
|
2200
2556
|
console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
|
|
2201
2557
|
}
|
|
2202
2558
|
const cm = agent.getContextManager();
|
|
2203
|
-
const tools = this.
|
|
2559
|
+
const tools = this.compressionToolsForAgent(agent.name);
|
|
2204
2560
|
cm.setToolDefinitions(tools);
|
|
2205
2561
|
if (this.providerGateBlocked(agent.name)) return [];
|
|
2206
2562
|
if (cm.isReady()) return [];
|
|
@@ -2284,10 +2640,74 @@ export class AgentFramework {
|
|
|
2284
2640
|
: undefined;
|
|
2285
2641
|
}
|
|
2286
2642
|
|
|
2643
|
+
/**
|
|
2644
|
+
* A server's request or notification the host couldn't handle, answered
|
|
2645
|
+
* with a JSON-RPC error when it is a request (a notification has no one to
|
|
2646
|
+
* answer, and is logged), rather than thrown out of the connection's
|
|
2647
|
+
* listener, where it was an unhandled rejection and the server's request
|
|
2648
|
+
* waited for its timeout.
|
|
2649
|
+
* - `refused`: the listener turned it away before its handler ran; the
|
|
2650
|
+
* host is stopping, and none of it was taken. Answered -32000, "the host
|
|
2651
|
+
* is stopping", the code the push coalescer already gives a stopping
|
|
2652
|
+
* host: the server can send it again to the host's next run.
|
|
2653
|
+
* - `failed`: the handler threw, whatever it threw, `undefined` included.
|
|
2654
|
+
* While the host stops (its queue closed), that's answered -32000 too,
|
|
2655
|
+
* with the handler's own message after the stop wording; on a live host
|
|
2656
|
+
* it's a fault, answered -32603 with the handler's message. The handler
|
|
2657
|
+
* may have admitted part of the request first (an earlier message of a
|
|
2658
|
+
* batch), so nothing here claims that none of it was taken.
|
|
2659
|
+
* A request the handler already answered before it threw isn't answered
|
|
2660
|
+
* again: the failure is logged.
|
|
2661
|
+
*/
|
|
2662
|
+
private refuseServerItem(
|
|
2663
|
+
method: string,
|
|
2664
|
+
serverId: string,
|
|
2665
|
+
answer: AnswerOnce,
|
|
2666
|
+
outcome: { kind: 'refused' } | { kind: 'failed'; error: unknown },
|
|
2667
|
+
): void {
|
|
2668
|
+
// Optional chaining: prototype-built harnesses leave the queue unset.
|
|
2669
|
+
const stopping = outcome.kind === 'refused' || !!this.queue?.isClosed;
|
|
2670
|
+
const what = outcome.kind === 'refused' ? '' : handlerFailureText(outcome.error);
|
|
2671
|
+
const reason = outcome.kind === 'refused'
|
|
2672
|
+
? 'the host is stopping'
|
|
2673
|
+
: stopping
|
|
2674
|
+
? `the host stopped while handling this request (${what})`
|
|
2675
|
+
: what;
|
|
2676
|
+
if (outcome.kind === 'failed' && answer.answered()) {
|
|
2677
|
+
console.error(`[mcpl] ${method} from ${serverId} failed after it was answered: ${reason}`);
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
console.error(outcome.kind === 'refused'
|
|
2681
|
+
? `[mcpl] ${method} from ${serverId} refused: ${reason}`
|
|
2682
|
+
: `[mcpl] ${method} from ${serverId} failed: ${reason} (part of it may already have been admitted)`);
|
|
2683
|
+
try {
|
|
2684
|
+
answer.responder?.respondError?.(stopping ? -32000 : -32603, reason);
|
|
2685
|
+
} catch {
|
|
2686
|
+
// The connection is going too; there is no one left to tell.
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2287
2690
|
/**
|
|
2288
2691
|
* Push a process event to the queue.
|
|
2692
|
+
*
|
|
2693
|
+
* After stop() the queue is closed and this throws, so a direct caller
|
|
2694
|
+
* learns the framework is stopped — except for a tool's result. A call
|
|
2695
|
+
* still running at stop() completes later, into a callback with nothing
|
|
2696
|
+
* to catch the throw (an unhandled rejection), and nothing could take its
|
|
2697
|
+
* result anyway: the queue that would carry it to its stream is closed,
|
|
2698
|
+
* stop() is ending the streams, and the tool has already run. That
|
|
2699
|
+
* result is dropped, and the drop is logged.
|
|
2289
2700
|
*/
|
|
2290
2701
|
pushEvent(event: ProcessEvent): void {
|
|
2702
|
+
if (event.type === 'tool-result' && this.queue?.isClosed) {
|
|
2703
|
+
// What an operator reconciling a dropped result needs (the resident
|
|
2704
|
+
// may run the tool again): which tool, and whether it succeeded.
|
|
2705
|
+
const outcome = event.result.success && !event.result.isError
|
|
2706
|
+
? 'succeeded'
|
|
2707
|
+
: `failed${event.result.error ? `: ${thrownText(event.result.error)}` : ''}`;
|
|
2708
|
+
console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived after stop — dropped; the tool ${outcome}`);
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2291
2711
|
this.queue.push(event);
|
|
2292
2712
|
this.emitTrace({ type: 'process:received', processEvent: event });
|
|
2293
2713
|
}
|
|
@@ -2330,6 +2750,7 @@ export class AgentFramework {
|
|
|
2330
2750
|
* Add a module at runtime.
|
|
2331
2751
|
*/
|
|
2332
2752
|
async addModule(module: Module): Promise<void> {
|
|
2753
|
+
this.resetCompletedStop();
|
|
2333
2754
|
await this.moduleRegistry.addModule(module);
|
|
2334
2755
|
this.emitTrace({ type: 'module:added', moduleName: module.name });
|
|
2335
2756
|
}
|
|
@@ -2449,7 +2870,14 @@ export class AgentFramework {
|
|
|
2449
2870
|
if (!mount) return null;
|
|
2450
2871
|
const path = `${mount}/refs/${fileName}`;
|
|
2451
2872
|
const res = await workspace.writeBinary(path, data, mimeType);
|
|
2452
|
-
|
|
2873
|
+
if (res.success) return path;
|
|
2874
|
+
// Stored, with its disk copy failed: the workspace's tools read it
|
|
2875
|
+
// from the tree, so the reference is fetched to that path.
|
|
2876
|
+
if ((res.data as { inChronicle?: boolean } | undefined)?.inChronicle === true) {
|
|
2877
|
+
console.error(`[refs] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${res.error ?? 'unknown error'}`);
|
|
2878
|
+
return path;
|
|
2879
|
+
}
|
|
2880
|
+
return null;
|
|
2453
2881
|
},
|
|
2454
2882
|
);
|
|
2455
2883
|
}
|
|
@@ -2622,7 +3050,38 @@ export class AgentFramework {
|
|
|
2622
3050
|
};
|
|
2623
3051
|
}
|
|
2624
3052
|
|
|
2625
|
-
|
|
3053
|
+
/** Every guard's annotation still running, for stop() (agent-framework #277). */
|
|
3054
|
+
private readonly guardAnnotations = new Set<Promise<void>>();
|
|
3055
|
+
/** Each guard that isn't idle, by agent name, as the guard reports it. An
|
|
3056
|
+
* ephemeral run or a closed fork leaves the agent map with an annotation
|
|
3057
|
+
* maybe still running, or a stub edit, acceptance edit or audit write
|
|
3058
|
+
* queued, which maintenance and stop then retry (agent-framework #277). A
|
|
3059
|
+
* guard is here from the moment it has work left until the moment it has
|
|
3060
|
+
* none, however that comes about. */
|
|
3061
|
+
private readonly busyGuards = new Map<ToolResultGuard, string>();
|
|
3062
|
+
/** Withheld originals' writes still under way past their deadline: while
|
|
3063
|
+
* any is, the workspace isn't answering and no other withheld original's
|
|
3064
|
+
* write is started. Several can be, when writes of different agents'
|
|
3065
|
+
* batches stalled together. */
|
|
3066
|
+
private readonly stalledSpills = new Set<Promise<void>>();
|
|
3067
|
+
/** Withheld spills skipped since the current stall began. */
|
|
3068
|
+
private stallSkips = 0;
|
|
3069
|
+
/** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
|
|
3070
|
+
private withheldSpillDeadlineMs = WITHHELD_SPILL_DEADLINE_MS;
|
|
3071
|
+
/** The lifecycle generation whose stop() has let the running annotations
|
|
3072
|
+
* finish: a withheld original is then no longer written to a workspace
|
|
3073
|
+
* that is stopping, and its stub names the audit record. A start() after
|
|
3074
|
+
* that stop() has finished has a generation of its own, so the spill gate
|
|
3075
|
+
* is open again for an instance kept with a store it doesn't own and that
|
|
3076
|
+
* is still open (start() doesn't restore stopped modules or MCPL
|
|
3077
|
+
* connections, and refuses an owned store that stop() closed). A start()
|
|
3078
|
+
* made while a stop() is still draining isn't a supported order. */
|
|
3079
|
+
private guardSpillsClosedFor: number | null = null;
|
|
3080
|
+
|
|
3081
|
+
/** A new agent's guard: its persisted setting, the tool-results spill its
|
|
3082
|
+
* withheld originals are written to, and the set stop() waits on
|
|
3083
|
+
* (agent-framework #277). */
|
|
3084
|
+
private adoptToolResultGuard(agent: Agent): void {
|
|
2626
3085
|
const enabled = (this.store.getStateJson(FRAMEWORK_STATE_ID) as {
|
|
2627
3086
|
toolResultGuards?: Record<string, unknown>;
|
|
2628
3087
|
} | null)?.toolResultGuards?.[agent.name];
|
|
@@ -2630,6 +3089,28 @@ export class AgentFramework {
|
|
|
2630
3089
|
throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
|
|
2631
3090
|
}
|
|
2632
3091
|
agent.toolResultGuard.setOverride(enabled);
|
|
3092
|
+
const guard = agent.toolResultGuard;
|
|
3093
|
+
guard.setHost({
|
|
3094
|
+
spill: (label, text, branch, deadline) => this.spillWithheld(label, text, branch, deadline),
|
|
3095
|
+
deadline: () => Date.now() + this.withheldSpillDeadlineMs,
|
|
3096
|
+
track: (annotation) => {
|
|
3097
|
+
this.guardAnnotations.add(annotation);
|
|
3098
|
+
void annotation.finally(() => this.guardAnnotations.delete(annotation));
|
|
3099
|
+
},
|
|
3100
|
+
idle: (idle) => {
|
|
3101
|
+
if (idle) this.busyGuards.delete(guard);
|
|
3102
|
+
else this.busyGuards.set(guard, agent.name);
|
|
3103
|
+
},
|
|
3104
|
+
});
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/** Each guard that may have queued storage work, once, by agent name: the
|
|
3108
|
+
* live agents' and every other that isn't idle. */
|
|
3109
|
+
private guardsWithStorageWork(): Map<ToolResultGuard, string> {
|
|
3110
|
+
const guards = new Map<ToolResultGuard, string>();
|
|
3111
|
+
for (const agent of this.agents.values()) guards.set(agent.toolResultGuard, agent.name);
|
|
3112
|
+
for (const [guard, name] of this.busyGuards) if (!guards.has(guard)) guards.set(guard, name);
|
|
3113
|
+
return guards;
|
|
2633
3114
|
}
|
|
2634
3115
|
|
|
2635
3116
|
private buildThinkTool(
|
|
@@ -2715,7 +3196,7 @@ export class AgentFramework {
|
|
|
2715
3196
|
: t);
|
|
2716
3197
|
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
2717
3198
|
}
|
|
2718
|
-
return this.getAllTools().map((tool) => {
|
|
3199
|
+
return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])].map((tool) => {
|
|
2719
3200
|
if (tool.name === 'think') {
|
|
2720
3201
|
return this.buildThinkTool(
|
|
2721
3202
|
snapshot?.sameRoundThinkTextPolicy
|
|
@@ -2726,6 +3207,53 @@ export class AgentFramework {
|
|
|
2726
3207
|
});
|
|
2727
3208
|
}
|
|
2728
3209
|
|
|
3210
|
+
/** Available is independent of presentation visibility; execution callers keep this surface. */
|
|
3211
|
+
private availableToolsForPresentation(agentName: string, snapshot?: InferenceToolSnapshot) {
|
|
3212
|
+
const agent = this.agents.get(agentName);
|
|
3213
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
3214
|
+
const tools = this.getToolsForAgent(agentName, snapshot).filter(t => agent.canUseTool(t.name));
|
|
3215
|
+
if (agent.proseRouting === 'explicit' && agent.canUseTool(PROSE_HELP_TOOL.name)) tools.push(PROSE_HELP_TOOL);
|
|
3216
|
+
return tools;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
inspectToolPresentation(agentName: string, snapshot?: InferenceToolSnapshot): PresentationSnapshot | null {
|
|
3220
|
+
const presentation = this.toolPresentations.get(agentName);
|
|
3221
|
+
if (!presentation) return null;
|
|
3222
|
+
const tools = this.availableToolsForPresentation(agentName, snapshot);
|
|
3223
|
+
const sources = new Map<string, string>();
|
|
3224
|
+
for (const tool of this.moduleRegistry.getAllTools()) {
|
|
3225
|
+
sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`);
|
|
3226
|
+
}
|
|
3227
|
+
// Use registered server prefixes, not a guess from a conventional mcpl-- name.
|
|
3228
|
+
for (const tool of tools) {
|
|
3229
|
+
for (const config of this.mcplServerConfigs.values()) {
|
|
3230
|
+
if (tool.name.startsWith((config.toolPrefix ?? `mcpl--${config.id}`) + '--')) {
|
|
3231
|
+
sources.set(tool.name, `MCPL server: ${config.id}`); break;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
if (!sources.has(tool.name)) sources.set(tool.name, 'Framework');
|
|
3235
|
+
}
|
|
3236
|
+
return presentation.resolve(tools, sources);
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
private advertisedToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) {
|
|
3240
|
+
return this.inspectToolPresentation(agentName, snapshot)?.advertised
|
|
3241
|
+
?? this.availableToolsForPresentation(agentName, snapshot);
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
/** Compression can revisit calls to hidden tools; never apply visibility here. */
|
|
3245
|
+
private compressionToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) {
|
|
3246
|
+
return this.inspectToolPresentation(agentName, snapshot)?.available
|
|
3247
|
+
?? this.availableToolsForPresentation(agentName, snapshot);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
private editToolPresentation(agentName: string, call: ToolCall): ToolResult {
|
|
3251
|
+
const presentation = this.toolPresentations.get(agentName);
|
|
3252
|
+
const agent = this.agents.get(agentName);
|
|
3253
|
+
if (!presentation || !agent?.canUseTool(call.name)) return {success:false,isError:true,error:'Tool presentation is not enabled for this caller'};
|
|
3254
|
+
return presentation.edit(call.name, call.input, this.availableToolsForPresentation(agentName));
|
|
3255
|
+
}
|
|
3256
|
+
|
|
2729
3257
|
/**
|
|
2730
3258
|
* The tools one agent is shown at inference: its surface (the subconscious
|
|
2731
3259
|
* has its own), less what its permissions deny, plus — for explicit-mode
|
|
@@ -2737,9 +3265,7 @@ export class AgentFramework {
|
|
|
2737
3265
|
agent: Agent,
|
|
2738
3266
|
snapshot?: InferenceToolSnapshot,
|
|
2739
3267
|
): import('./types/index.js').ToolDefinition[] {
|
|
2740
|
-
|
|
2741
|
-
if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
|
|
2742
|
-
return tools;
|
|
3268
|
+
return this.advertisedToolsForAgent(agent.name, snapshot);
|
|
2743
3269
|
}
|
|
2744
3270
|
|
|
2745
3271
|
getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
|
|
@@ -3558,7 +4084,12 @@ export class AgentFramework {
|
|
|
3558
4084
|
throw new Error(`Agent not found: ${agentName}`);
|
|
3559
4085
|
}
|
|
3560
4086
|
|
|
3561
|
-
const
|
|
4087
|
+
const presentation = this.inspectToolPresentation(agentName);
|
|
4088
|
+
const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName);
|
|
4089
|
+
const capture = (request: NormalizedRequest) => {
|
|
4090
|
+
if (presentation) this.presentationPreviews.set(request, presentation);
|
|
4091
|
+
return request;
|
|
4092
|
+
};
|
|
3562
4093
|
|
|
3563
4094
|
// An explicit budget compiles against a HYPOTHETICAL window instead of the
|
|
3564
4095
|
// agent's live one. That also suppresses transition-settling in
|
|
@@ -3567,7 +4098,7 @@ export class AgentFramework {
|
|
|
3567
4098
|
// Default: no dynamic injection gathering → fully transparent (no
|
|
3568
4099
|
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
3569
4100
|
if (!opts?.injections) {
|
|
3570
|
-
return agent.buildActivationRequest(tools, undefined, opts?.budget);
|
|
4101
|
+
return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
|
|
3571
4102
|
}
|
|
3572
4103
|
|
|
3573
4104
|
// Full-fidelity path: mirrors startAgentStream's injection gathering.
|
|
@@ -3602,7 +4133,7 @@ export class AgentFramework {
|
|
|
3602
4133
|
}
|
|
3603
4134
|
}
|
|
3604
4135
|
|
|
3605
|
-
return agent.buildActivationRequest(tools, injections, opts?.budget);
|
|
4136
|
+
return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
|
|
3606
4137
|
}
|
|
3607
4138
|
|
|
3608
4139
|
/**
|
|
@@ -4029,7 +4560,7 @@ export class AgentFramework {
|
|
|
4029
4560
|
});
|
|
4030
4561
|
|
|
4031
4562
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
4032
|
-
this.
|
|
4563
|
+
this.adoptToolResultGuard(agent);
|
|
4033
4564
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
4034
4565
|
this.ephemeralCandidates.set(agent, contextManager);
|
|
4035
4566
|
|
|
@@ -4083,13 +4614,22 @@ export class AgentFramework {
|
|
|
4083
4614
|
run.lastActivity = Date.now();
|
|
4084
4615
|
}
|
|
4085
4616
|
|
|
4086
|
-
/**
|
|
4087
|
-
*
|
|
4088
|
-
*
|
|
4617
|
+
/** A logical turn's end, for every agent: it closes the agent's open
|
|
4618
|
+
* logical turn when it is the settling stream's own (openLogical), and
|
|
4619
|
+
* settles an active ephemeral run. The run's tool-call count comes from
|
|
4620
|
+
* its own counter — the single source of truth, which unlike a
|
|
4621
|
+
* per-stream local survives budget restarts. */
|
|
4089
4622
|
private settleAgent(
|
|
4090
4623
|
agentName: string,
|
|
4091
4624
|
outcome: { stopReason: AgentSettleResult['stopReason']; speech: string; error?: string },
|
|
4625
|
+
/** The settling stream's logical turn, when that stream still owns the
|
|
4626
|
+
* agent: it ends here, so the agent's next fresh start opens a new one.
|
|
4627
|
+
* A settle from a stream a newer one replaced closes nothing. */
|
|
4628
|
+
closes: LogicalTurn | undefined,
|
|
4092
4629
|
): void {
|
|
4630
|
+
// This identity check is the only guard against a stale settle closing
|
|
4631
|
+
// a newer turn (no test reaches one); keep it when editing call sites.
|
|
4632
|
+
if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName);
|
|
4093
4633
|
const run = this.ephemeralRuns.get(agentName);
|
|
4094
4634
|
if (!run) return;
|
|
4095
4635
|
run.lastActivity = Date.now();
|
|
@@ -4231,6 +4771,9 @@ export class AgentFramework {
|
|
|
4231
4771
|
// generation mismatch and leave any future owner untouched.
|
|
4232
4772
|
this.activeStreams.delete(agent.name);
|
|
4233
4773
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
4774
|
+
this.turnRouting.delete(agent.name);
|
|
4775
|
+
this.proseSpeechChains.delete(agent.name);
|
|
4776
|
+
this.openLogical.delete(agent.name);
|
|
4234
4777
|
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
4235
4778
|
// deregistered, so disposal must release EventGate liveness itself.
|
|
4236
4779
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
@@ -6335,7 +6878,7 @@ export class AgentFramework {
|
|
|
6335
6878
|
});
|
|
6336
6879
|
|
|
6337
6880
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
6338
|
-
this.
|
|
6881
|
+
this.adoptToolResultGuard(agent);
|
|
6339
6882
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
6340
6883
|
this.sharedSlotAgents.add(agent);
|
|
6341
6884
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -6412,7 +6955,7 @@ export class AgentFramework {
|
|
|
6412
6955
|
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
6413
6956
|
};
|
|
6414
6957
|
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
6415
|
-
this.
|
|
6958
|
+
this.adoptToolResultGuard(agent);
|
|
6416
6959
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
6417
6960
|
this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
|
|
6418
6961
|
this.agents.set(name, agent);
|
|
@@ -6483,6 +7026,10 @@ export class AgentFramework {
|
|
|
6483
7026
|
const scriptWaiter = this.scriptToolWaiters.get(event.callId);
|
|
6484
7027
|
if (scriptWaiter) {
|
|
6485
7028
|
this.scriptToolWaiters.delete(event.callId);
|
|
7029
|
+
// RFC-007: the inner call's terminal. Also reached when the script
|
|
7030
|
+
// was killed while the call ran: the result reaches no one, but the
|
|
7031
|
+
// call did finish.
|
|
7032
|
+
this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
|
|
6486
7033
|
scriptWaiter(event.result);
|
|
6487
7034
|
return;
|
|
6488
7035
|
}
|
|
@@ -6514,6 +7061,44 @@ export class AgentFramework {
|
|
|
6514
7061
|
result: event.result,
|
|
6515
7062
|
});
|
|
6516
7063
|
}
|
|
7064
|
+
// The round's last pending result: let modules catch up with what the
|
|
7065
|
+
// batch's tools did (the workspace scans `on-agent-action` mounts) before
|
|
7066
|
+
// anything continues the turn. Bounded and fail-open; the agent may be
|
|
7067
|
+
// reset or cancelled meanwhile, so its state is checked again after.
|
|
7068
|
+
if (
|
|
7069
|
+
agent && agent.state.status === 'waiting_for_tools'
|
|
7070
|
+
&& agent.state.pending.size === 1 && agent.state.pending.has(event.callId)
|
|
7071
|
+
) {
|
|
7072
|
+
// The call this result answers, as this round holds it: a new round
|
|
7073
|
+
// that reuses the call's id holds a new entry for it.
|
|
7074
|
+
const owed = agent.state.pending.get(event.callId);
|
|
7075
|
+
const failures = await this.moduleRegistry.notifyToolBatchComplete(event.agentName);
|
|
7076
|
+
for (const failure of failures) {
|
|
7077
|
+
this.emitTrace({ type: 'module:batch_hook_failed', agentName: event.agentName, module: failure.module, error: failure.error });
|
|
7078
|
+
}
|
|
7079
|
+
// The turn may have been reset, cancelled or replaced while the hook
|
|
7080
|
+
// ran, even back to waiting on a new batch, whose calls may reuse this
|
|
7081
|
+
// one's id: this result is still owed only if this agent still waits
|
|
7082
|
+
// on this round's call.
|
|
7083
|
+
const after = agent.state as AgentState;
|
|
7084
|
+
if (
|
|
7085
|
+
this.agents.get(event.agentName) !== agent
|
|
7086
|
+
|| after.status !== 'waiting_for_tools' || after.pending.get(event.callId) !== owed
|
|
7087
|
+
) {
|
|
7088
|
+
console.warn(
|
|
7089
|
+
`[framework] Dropping tool-result for agent '${event.agentName}' (callId=${event.callId}): ` +
|
|
7090
|
+
`its turn changed while modules caught up with the batch (status '${after.status}').`,
|
|
7091
|
+
);
|
|
7092
|
+
this.emitTrace({
|
|
7093
|
+
type: 'tool:result_dropped',
|
|
7094
|
+
agentName: event.agentName,
|
|
7095
|
+
callId: event.callId,
|
|
7096
|
+
agentStatus: (agent.state as AgentState).status,
|
|
7097
|
+
result: event.result,
|
|
7098
|
+
});
|
|
7099
|
+
return;
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
6517
7102
|
if (agent && agent.state.status === 'waiting_for_tools') {
|
|
6518
7103
|
agent.provideToolResult(event.callId, event.result);
|
|
6519
7104
|
|
|
@@ -6523,8 +7108,9 @@ export class AgentFramework {
|
|
|
6523
7108
|
if (currentState.status === 'ready') {
|
|
6524
7109
|
// Flush pending assistant blocks (tool_use + preamble text) to context
|
|
6525
7110
|
const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
|
|
7111
|
+
const turnRowMetadata = this.silentTurnRowMetadata(agent.name);
|
|
6526
7112
|
if (pendingBlocks) {
|
|
6527
|
-
agent.addAssistantResponse(pendingBlocks);
|
|
7113
|
+
agent.addAssistantResponse(pendingBlocks, turnRowMetadata);
|
|
6528
7114
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
6529
7115
|
}
|
|
6530
7116
|
|
|
@@ -6543,7 +7129,7 @@ export class AgentFramework {
|
|
|
6543
7129
|
const membraneResults = currentState.toolResults.map(tc =>
|
|
6544
7130
|
this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
|
|
6545
7131
|
);
|
|
6546
|
-
agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
|
|
7132
|
+
agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata);
|
|
6547
7133
|
|
|
6548
7134
|
// Flush any messages that were deferred while this turn was in
|
|
6549
7135
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
@@ -6739,7 +7325,7 @@ export class AgentFramework {
|
|
|
6739
7325
|
// agent must be wakeable the moment it goes idle. (Idempotent;
|
|
6740
7326
|
// driveStream's finally is the backstop.)
|
|
6741
7327
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
6742
|
-
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
7328
|
+
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical);
|
|
6743
7329
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
6744
7330
|
} else if (overBudget || overPhysical) {
|
|
6745
7331
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
@@ -6780,6 +7366,7 @@ export class AgentFramework {
|
|
|
6780
7366
|
timestamp: Date.now(),
|
|
6781
7367
|
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
6782
7368
|
ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
|
|
7369
|
+
silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
|
|
6783
7370
|
});
|
|
6784
7371
|
} else if (currentState.stream) {
|
|
6785
7372
|
// Streaming path: convert results and resume the stream.
|
|
@@ -6797,7 +7384,8 @@ export class AgentFramework {
|
|
|
6797
7384
|
...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
|
|
6798
7385
|
});
|
|
6799
7386
|
} else {
|
|
6800
|
-
// Non-streaming fallback: schedule re-inference
|
|
7387
|
+
// Non-streaming fallback: schedule re-inference, which continues
|
|
7388
|
+
// this stream's logical turn (openLogical).
|
|
6801
7389
|
this.pendingRequests.push({
|
|
6802
7390
|
agentName: agent.name,
|
|
6803
7391
|
reason: 'tool_results_ready',
|
|
@@ -6969,6 +7557,16 @@ export class AgentFramework {
|
|
|
6969
7557
|
assemblingFor?: string;
|
|
6970
7558
|
deliverTo?: string;
|
|
6971
7559
|
}): Promise<CoalescingPlacement | undefined> {
|
|
7560
|
+
// Same backstop as the push lane: no row, no wake, no fork spawned for a
|
|
7561
|
+
// message with nothing visible in it.
|
|
7562
|
+
if (isVisiblyEmptyContent(event.content)) {
|
|
7563
|
+
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
|
|
7564
|
+
this.emitTrace({
|
|
7565
|
+
type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
|
|
7566
|
+
channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
|
|
7567
|
+
});
|
|
7568
|
+
return undefined;
|
|
7569
|
+
}
|
|
6972
7570
|
const metadata: Record<string, unknown> = {
|
|
6973
7571
|
...event.metadata,
|
|
6974
7572
|
...(event.eventId ? { eventId: event.eventId } : {}),
|
|
@@ -7239,7 +7837,11 @@ export class AgentFramework {
|
|
|
7239
7837
|
// seeding again would stack another template copy on top of it.
|
|
7240
7838
|
const { messages: existing } = await contextManager.compile();
|
|
7241
7839
|
if (existing.length === 0) {
|
|
7242
|
-
|
|
7840
|
+
// The copy is the fork's own, and the template's annotation edits only
|
|
7841
|
+
// the template's message: seed only once each of the template's
|
|
7842
|
+
// annotations has finished.
|
|
7843
|
+
const { messages: compiled } = await template.toolResultGuard.afterAnnotations(
|
|
7844
|
+
() => template.getContextManager().compile());
|
|
7243
7845
|
for (const msg of compiled) {
|
|
7244
7846
|
const participant = msg.participant === template.name ? name : msg.participant;
|
|
7245
7847
|
contextManager.addMessage(participant, msg.content);
|
|
@@ -7248,7 +7850,7 @@ export class AgentFramework {
|
|
|
7248
7850
|
|
|
7249
7851
|
const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
|
|
7250
7852
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
7251
|
-
this.
|
|
7853
|
+
this.adoptToolResultGuard(agent);
|
|
7252
7854
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
7253
7855
|
this.agents.set(name, agent);
|
|
7254
7856
|
this.agentConfigs.set(name, config);
|
|
@@ -7283,6 +7885,9 @@ export class AgentFramework {
|
|
|
7283
7885
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
7284
7886
|
const agent = this.agents.get(agentName);
|
|
7285
7887
|
this.agents.delete(agentName);
|
|
7888
|
+
this.turnRouting.delete(agentName);
|
|
7889
|
+
this.proseSpeechChains.delete(agentName);
|
|
7890
|
+
this.openLogical.delete(agentName);
|
|
7286
7891
|
this.toolImageLedgers.delete(agentName);
|
|
7287
7892
|
this.agentConfigs.delete(agentName);
|
|
7288
7893
|
this.conversationAgentHomes.delete(agentName);
|
|
@@ -7611,7 +8216,7 @@ export class AgentFramework {
|
|
|
7611
8216
|
throw new CoalesceError('eventId', 'eventId is required with coalesce');
|
|
7612
8217
|
}
|
|
7613
8218
|
if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7614
|
-
validateCoalescedContent(message.content);
|
|
8219
|
+
validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
|
|
7615
8220
|
const c = message.coalesce;
|
|
7616
8221
|
const result = await this.pushCoalescer.accept({
|
|
7617
8222
|
serverId,
|
|
@@ -7638,7 +8243,11 @@ export class AgentFramework {
|
|
|
7638
8243
|
validateCoalesceMember(params.coalesce, 'push');
|
|
7639
8244
|
if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
|
|
7640
8245
|
if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7641
|
-
validateCoalescedContent(params.payload?.content
|
|
8246
|
+
validateCoalescedContent(params.payload?.content, undefined, {
|
|
8247
|
+
allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
|
|
8248
|
+
serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
|
|
8249
|
+
}),
|
|
8250
|
+
});
|
|
7642
8251
|
const c = params.coalesce;
|
|
7643
8252
|
if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId);
|
|
7644
8253
|
const origin = params.origin ?? {};
|
|
@@ -7810,6 +8419,7 @@ export class AgentFramework {
|
|
|
7810
8419
|
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
7811
8420
|
addressed: isAddressedMessage(occ.tags, origin),
|
|
7812
8421
|
coalescingSubject: subject,
|
|
8422
|
+
coalescingBatch: true,
|
|
7813
8423
|
});
|
|
7814
8424
|
}
|
|
7815
8425
|
}
|
|
@@ -7924,6 +8534,16 @@ export class AgentFramework {
|
|
|
7924
8534
|
* Convert an MCPL push event to a context message.
|
|
7925
8535
|
*/
|
|
7926
8536
|
private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined {
|
|
8537
|
+
// Backstop for the MCPL boundary's empty-content rejection, covering
|
|
8538
|
+
// events that never crossed it (module-emitted, coalescer deliveries):
|
|
8539
|
+
// content with nothing visible stores no row and queues no wake — the
|
|
8540
|
+
// model would wake to `[Continue]` or an older message re-presented as
|
|
8541
|
+
// the newest, a wake with no visible cause.
|
|
8542
|
+
if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
|
|
8543
|
+
console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
|
|
8544
|
+
this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
|
|
8545
|
+
return undefined;
|
|
8546
|
+
}
|
|
7927
8547
|
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
7928
8548
|
if (triggerChannel && this.channelRegistry) {
|
|
7929
8549
|
this.channelRegistry.ensureChannelRegistered(
|
|
@@ -7978,12 +8598,7 @@ export class AgentFramework {
|
|
|
7978
8598
|
// Accept the no-message path only for the heartbeat feature's own exact
|
|
7979
8599
|
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
7980
8600
|
// content merely by setting `origin.silent`.
|
|
7981
|
-
const silentHeartbeat =
|
|
7982
|
-
event.serverId === 'heartbeat' &&
|
|
7983
|
-
event.featureSet === 'heartbeat' &&
|
|
7984
|
-
event.origin?.source === 'heartbeat' &&
|
|
7985
|
-
event.origin?.silent === true &&
|
|
7986
|
-
content.length === 0;
|
|
8601
|
+
const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
|
|
7987
8602
|
|
|
7988
8603
|
const placement: CoalescingPlacement = { agent: '' };
|
|
7989
8604
|
if (!silentHeartbeat) {
|
|
@@ -8014,6 +8629,7 @@ export class AgentFramework {
|
|
|
8014
8629
|
ephemeralSystemPrompt:
|
|
8015
8630
|
'[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
|
|
8016
8631
|
'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
|
|
8632
|
+
silentHeartbeat: { eventId: event.eventId, serverId: event.serverId },
|
|
8017
8633
|
} : {}),
|
|
8018
8634
|
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
8019
8635
|
});
|
|
@@ -8022,6 +8638,21 @@ export class AgentFramework {
|
|
|
8022
8638
|
return placement.agent ? placement : undefined;
|
|
8023
8639
|
}
|
|
8024
8640
|
|
|
8641
|
+
/**
|
|
8642
|
+
* Whether a batched gate wake's addressed route may become `agentName`'s
|
|
8643
|
+
* speech locus. The gate broadcasts to every agent, conversation forks
|
|
8644
|
+
* included. A fork speaks only in its home channel, and a channel bound to
|
|
8645
|
+
* a fork is that fork's conversation, so no other agent takes it.
|
|
8646
|
+
*/
|
|
8647
|
+
private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean {
|
|
8648
|
+
const home = this.conversationAgentHomes.get(agentName);
|
|
8649
|
+
if (home !== undefined) return home === routeChannelId;
|
|
8650
|
+
for (const boundChannel of this.conversationAgentHomes.values()) {
|
|
8651
|
+
if (boundChannel === routeChannelId) return false;
|
|
8652
|
+
}
|
|
8653
|
+
return true;
|
|
8654
|
+
}
|
|
8655
|
+
|
|
8025
8656
|
/**
|
|
8026
8657
|
* Derive the MCPL composite channel id (the outbound routing locus) for a
|
|
8027
8658
|
* push event from its server-defined `origin`, if it carries one.
|
|
@@ -8289,19 +8920,31 @@ export class AgentFramework {
|
|
|
8289
8920
|
// and counterparty must come from the same message, or a batch of
|
|
8290
8921
|
// "ambient from A, then addressed from B" would report B's channel
|
|
8291
8922
|
// with A's author (review finding on the provenance change).
|
|
8923
|
+
//
|
|
8924
|
+
// "Most recent" is by the EVENT's time (wakeAt, else the request's own
|
|
8925
|
+
// timestamp), not queue order. A gate wake is queued when its debounce
|
|
8926
|
+
// flushes, which can be long after its event: an addressed message
|
|
8927
|
+
// held in the gate while the agent was busy lands BEHIND a newer
|
|
8928
|
+
// addressed message's direct wake, and queue order would hand the turn
|
|
8929
|
+
// to the older one. Ties keep the later-queued request, as before.
|
|
8930
|
+
const eventAt = (r: InferenceRequest): number => r.wakeAt ?? r.timestamp;
|
|
8292
8931
|
let ambientReq: InferenceRequest | undefined;
|
|
8293
8932
|
let addressedReq: InferenceRequest | undefined;
|
|
8294
8933
|
for (const r of requests) {
|
|
8295
8934
|
if (!r.channelId) continue;
|
|
8296
|
-
ambientReq = r;
|
|
8297
|
-
if (r.addressed) addressedReq = r;
|
|
8935
|
+
if (!ambientReq || eventAt(r) >= eventAt(ambientReq)) ambientReq = r;
|
|
8936
|
+
if (r.addressed && (!addressedReq || eventAt(r) >= eventAt(addressedReq))) addressedReq = r;
|
|
8298
8937
|
}
|
|
8299
8938
|
const channelReq = addressedReq ?? ambientReq;
|
|
8300
|
-
// Telemetry provenance (who/where woke the agent)
|
|
8301
|
-
//
|
|
8302
|
-
//
|
|
8303
|
-
//
|
|
8304
|
-
|
|
8939
|
+
// Telemetry provenance (who/where woke the agent). An ADDRESSED winner
|
|
8940
|
+
// is its own provenance, even when it names no author (a push event
|
|
8941
|
+
// can route without an author id): borrowing another request's author
|
|
8942
|
+
// would credit the turn to someone who did not address the agent.
|
|
8943
|
+
// Otherwise from the routing winner when it carries one; else from the
|
|
8944
|
+
// most recent request that does (ambient gate wakes name a counterparty
|
|
8945
|
+
// without setting a locus), ordered by the event's own time.
|
|
8946
|
+
let provReq: InferenceRequest | undefined = addressedReq
|
|
8947
|
+
?? (channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined);
|
|
8305
8948
|
if (!provReq) {
|
|
8306
8949
|
for (const r of requests) {
|
|
8307
8950
|
if (!r.counterparty && !r.wakeChannelId) continue;
|
|
@@ -8311,10 +8954,19 @@ export class AgentFramework {
|
|
|
8311
8954
|
// A silent tick never suppresses a genuine coalesced wake. If any
|
|
8312
8955
|
// ordinary request shares this batch, the ordinary turn semantics win.
|
|
8313
8956
|
const silentOnly = requests.every((r) => r.suppressProse === true);
|
|
8957
|
+
// RFC-006 §5: when every cause is a deferred batch, the turn's content
|
|
8958
|
+
// exists only once assembly renders it. Recorded for the whole batch
|
|
8959
|
+
// (not inherited from requests[0]), so a requeued trigger stays honest.
|
|
8960
|
+
const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
|
|
8314
8961
|
await this.startAgentStream(agent, {
|
|
8315
8962
|
...trigger,
|
|
8963
|
+
coalescingBatch: batchOnly || undefined,
|
|
8964
|
+
coalescingBatchSubjects: batchOnly
|
|
8965
|
+
? [...new Set(requests.flatMap((r) => r.coalescingBatchSubjects ?? [r.coalescingSubject!]))]
|
|
8966
|
+
: undefined,
|
|
8316
8967
|
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
|
|
8317
8968
|
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
|
|
8969
|
+
silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
|
|
8318
8970
|
channelId: channelReq?.channelId,
|
|
8319
8971
|
addressed: addressedReq !== undefined,
|
|
8320
8972
|
// A context-budget restart continues the same logical turn: it keeps
|
|
@@ -8328,24 +8980,36 @@ export class AgentFramework {
|
|
|
8328
8980
|
}
|
|
8329
8981
|
}
|
|
8330
8982
|
|
|
8331
|
-
/**
|
|
8332
|
-
|
|
8983
|
+
/**
|
|
8984
|
+
* Metadata for rows the agent's current turn stores: a silent heartbeat
|
|
8985
|
+
* tick stamps each of them (assistant responses, tool_result rows) so they
|
|
8986
|
+
* stay identifiable after the turn — request builds key the tick separator
|
|
8987
|
+
* on it, and a later retention policy can find or collapse them. The stamp
|
|
8988
|
+
* names the agent: residents share one message slot and a broadcast tick
|
|
8989
|
+
* reaches each of them with the same eventId. Undefined for ordinary
|
|
8990
|
+
* turns, whose rows are stored exactly as before.
|
|
8991
|
+
*/
|
|
8992
|
+
private silentTurnRowMetadata(agentName: string): MessageMetadata | undefined {
|
|
8993
|
+
const tick = this.activeTurnTriggers.get(agentName)?.silentHeartbeat;
|
|
8994
|
+
const stamp: SilentHeartbeatStamp | undefined = tick
|
|
8995
|
+
? { eventId: tick.eventId, serverId: tick.serverId, agentName }
|
|
8996
|
+
: undefined;
|
|
8997
|
+
return stamp ? { silentHeartbeat: stamp } : undefined;
|
|
8998
|
+
}
|
|
8999
|
+
|
|
9000
|
+
/** Record plain-prose segments this turn suppressed. */
|
|
9001
|
+
private recordProseSuppression(routing: TurnRouting, count: number): void {
|
|
8333
9002
|
if (count <= 0) return;
|
|
8334
|
-
|
|
9003
|
+
routing.logical.suppressed += count;
|
|
8335
9004
|
}
|
|
8336
9005
|
|
|
8337
9006
|
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
8338
9007
|
private recordProseDelivery(
|
|
8339
|
-
|
|
9008
|
+
routing: TurnRouting,
|
|
8340
9009
|
outcome: { delivered: boolean; channelId: string } | null | undefined,
|
|
8341
9010
|
): void {
|
|
8342
9011
|
if (!outcome?.delivered) return;
|
|
8343
|
-
|
|
8344
|
-
if (!list) {
|
|
8345
|
-
list = [];
|
|
8346
|
-
this.turnProseDeliveries.set(agentName, list);
|
|
8347
|
-
}
|
|
8348
|
-
list.push(outcome.channelId);
|
|
9012
|
+
routing.logical.deliveries.push(outcome.channelId);
|
|
8349
9013
|
}
|
|
8350
9014
|
|
|
8351
9015
|
/**
|
|
@@ -8361,12 +9025,12 @@ export class AgentFramework {
|
|
|
8361
9025
|
* delivered nothing. Failures are already marked separately
|
|
8362
9026
|
* ([discord-send-failed]); this is the success half.
|
|
8363
9027
|
*/
|
|
8364
|
-
private appendProseDeliveryReceipt(agent: Agent): void {
|
|
8365
|
-
const list =
|
|
8366
|
-
const suppressed =
|
|
8367
|
-
if (
|
|
8368
|
-
|
|
8369
|
-
|
|
9028
|
+
private appendProseDeliveryReceipt(agent: Agent, routing: TurnRouting): void {
|
|
9029
|
+
const list = routing.logical.deliveries;
|
|
9030
|
+
const suppressed = routing.logical.suppressed;
|
|
9031
|
+
if (list.length === 0 && suppressed === 0) return;
|
|
9032
|
+
routing.logical.deliveries = [];
|
|
9033
|
+
routing.logical.suppressed = 0;
|
|
8370
9034
|
const seen = new Set<string>();
|
|
8371
9035
|
const shown: string[] = [];
|
|
8372
9036
|
for (const id of list ?? []) {
|
|
@@ -8494,6 +9158,47 @@ export class AgentFramework {
|
|
|
8494
9158
|
}
|
|
8495
9159
|
}
|
|
8496
9160
|
|
|
9161
|
+
/**
|
|
9162
|
+
* A `!` continuation the turn asked for (`>>#x !`, `>>>#x !`): start another
|
|
9163
|
+
* turn now instead of pausing until the next external event. It reads the
|
|
9164
|
+
* logical turn of the stream that asked (shared with a retry or a
|
|
9165
|
+
* re-inference that continues it), once its deliveries have run, when the
|
|
9166
|
+
* turn completes or a tool ends it. A stream that ends in an error asks for
|
|
9167
|
+
* none itself, but a framework retry that follows it carries the request;
|
|
9168
|
+
* a turn abandoned by a quiesce asks for none.
|
|
9169
|
+
*/
|
|
9170
|
+
private requestProseContinuation(agent: Agent, routing: TurnRouting): void {
|
|
9171
|
+
// The flag may be shared with an earlier stream of the same logical
|
|
9172
|
+
// turn (LogicalTurn); its deliveries are on the agent's chain ahead of
|
|
9173
|
+
// this stream's, so by the time this check runs, they have run.
|
|
9174
|
+
if (!routing.logical.continuation) return;
|
|
9175
|
+
routing.logical.continuation = false;
|
|
9176
|
+
// An explicit turn's check can run after its stream has returned. A
|
|
9177
|
+
// stopped framework, or an agent no longer registered under its name (a
|
|
9178
|
+
// disposed fork or ephemeral run), takes no new turn from it.
|
|
9179
|
+
if (this.providerAdmissionClosed || this.agents.get(agent.name) !== agent) {
|
|
9180
|
+
console.error(`[prose] ${agent.name}: '!' continuation dropped — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
|
|
9181
|
+
return;
|
|
9182
|
+
}
|
|
9183
|
+
console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
|
|
9184
|
+
this.pendingRequests.push({
|
|
9185
|
+
agentName: agent.name,
|
|
9186
|
+
reason: 'prose-continuation',
|
|
9187
|
+
source: 'framework',
|
|
9188
|
+
timestamp: Date.now(),
|
|
9189
|
+
});
|
|
9190
|
+
}
|
|
9191
|
+
|
|
9192
|
+
/** The agent's routing state for its current turn, made when it has none. */
|
|
9193
|
+
private routingOf(agentName: string): TurnRouting {
|
|
9194
|
+
let routing = this.turnRouting.get(agentName);
|
|
9195
|
+
if (!routing) {
|
|
9196
|
+
routing = freshTurnRouting();
|
|
9197
|
+
this.turnRouting.set(agentName, routing);
|
|
9198
|
+
}
|
|
9199
|
+
return routing;
|
|
9200
|
+
}
|
|
9201
|
+
|
|
8497
9202
|
/**
|
|
8498
9203
|
* Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
|
|
8499
9204
|
* Every prose segment of an `explicit`-mode agent flows through here:
|
|
@@ -8502,32 +9207,58 @@ export class AgentFramework {
|
|
|
8502
9207
|
* nothing is ever sent to a destination the model did not name (directly
|
|
8503
9208
|
* or via the turn's sticky target).
|
|
8504
9209
|
*/
|
|
8505
|
-
private async deliverProse(agent: Agent, rawText: string): Promise<void> {
|
|
8506
|
-
//
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
9210
|
+
private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise<void> {
|
|
9211
|
+
// The direct entry delivers under the live turn's routing.
|
|
9212
|
+
await this.deliverAdmittedProse(agent, this.admitProse(rawText), {
|
|
9213
|
+
keepPrivate: round?.keepPrivate,
|
|
9214
|
+
routing: this.routingOf(agent.name),
|
|
9215
|
+
});
|
|
9216
|
+
}
|
|
9217
|
+
|
|
9218
|
+
/**
|
|
9219
|
+
* Explicit prose's envelopes, each `>>` target resolved now, once. A live
|
|
9220
|
+
* round is admitted at its boundary: its turn's destination is decided from
|
|
9221
|
+
* these resolutions (roundDestination), and its queued delivery posts to
|
|
9222
|
+
* them, however the registry's names change before it runs.
|
|
9223
|
+
*/
|
|
9224
|
+
private admitProse(rawText: string): AdmittedEnvelope[] {
|
|
9225
|
+
return splitExplicitEnvelopes(rawText).map((text) => {
|
|
9226
|
+
const parsed = parseProsePrefix(text);
|
|
9227
|
+
if (parsed.kind !== 'target') return { text };
|
|
9228
|
+
try {
|
|
9229
|
+
return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) };
|
|
9230
|
+
} catch (err) {
|
|
9231
|
+
// Admission runs in the stream's event loop, so a resolver that
|
|
9232
|
+
// throws makes the envelope bounce rather than stop the round.
|
|
9233
|
+
return { text, resolved: { error: `the destination couldn't be resolved: ${err instanceof Error ? err.message : String(err)}` } };
|
|
8522
9234
|
}
|
|
8523
|
-
|
|
9235
|
+
});
|
|
9236
|
+
}
|
|
9237
|
+
|
|
9238
|
+
private async deliverAdmittedProse(
|
|
9239
|
+
agent: Agent,
|
|
9240
|
+
envelopes: AdmittedEnvelope[],
|
|
9241
|
+
round: ProseRound,
|
|
9242
|
+
): Promise<void> {
|
|
9243
|
+
// Multi-envelope: a single prose block may address SEVERAL destinations,
|
|
9244
|
+
// each envelope routed independently (sticky/bounce as usual).
|
|
9245
|
+
for (const envelope of envelopes) {
|
|
9246
|
+
await this.deliverProseEnvelope(agent, envelope, round);
|
|
8524
9247
|
}
|
|
8525
|
-
|
|
9248
|
+
}
|
|
8526
9249
|
|
|
9250
|
+
/**
|
|
9251
|
+
* Where a delivered round leaves its turn's sticky destination, as the
|
|
9252
|
+
* explicit gateway sets it once the round's envelopes run: each `>>`
|
|
9253
|
+
* envelope whose target resolved becomes the destination. One that didn't
|
|
9254
|
+
* resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text.
|
|
9255
|
+
*/
|
|
9256
|
+
private roundDestination(current: string | null, envelopes: AdmittedEnvelope[]): string | null {
|
|
9257
|
+
let destination = current;
|
|
8527
9258
|
for (const envelope of envelopes) {
|
|
8528
|
-
if (
|
|
8529
|
-
await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
|
|
9259
|
+
if (envelope.resolved && 'channelId' in envelope.resolved) destination = envelope.resolved.channelId;
|
|
8530
9260
|
}
|
|
9261
|
+
return destination;
|
|
8531
9262
|
}
|
|
8532
9263
|
|
|
8533
9264
|
/** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
|
|
@@ -8536,7 +9267,9 @@ export class AgentFramework {
|
|
|
8536
9267
|
rawText: string,
|
|
8537
9268
|
locus: string | null,
|
|
8538
9269
|
allowLocus: boolean,
|
|
9270
|
+
turnRouting: TurnRouting,
|
|
8539
9271
|
): Promise<void> {
|
|
9272
|
+
const routing = turnRouting;
|
|
8540
9273
|
const lines = rawText.split('\n');
|
|
8541
9274
|
const envelopes: string[] = [];
|
|
8542
9275
|
let current: string[] = [];
|
|
@@ -8554,77 +9287,85 @@ export class AgentFramework {
|
|
|
8554
9287
|
const normalized = envelope.replace(/^\s+(?=>>>)/, '');
|
|
8555
9288
|
const attempted = normalized.startsWith('>>>');
|
|
8556
9289
|
const parsed = parseHybridProsePrefix(normalized);
|
|
8557
|
-
if (parsed.continueTurn)
|
|
9290
|
+
if (parsed.continueTurn) routing.logical.continuation = true;
|
|
8558
9291
|
if (parsed.kind === 'private') {
|
|
8559
|
-
|
|
8560
|
-
|
|
9292
|
+
delete routing.pin;
|
|
9293
|
+
routing.hybridHold = true;
|
|
8561
9294
|
console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
8562
9295
|
continue;
|
|
8563
9296
|
}
|
|
8564
9297
|
if (parsed.kind === 'target') {
|
|
8565
9298
|
const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!);
|
|
8566
9299
|
if ('error' in resolved) {
|
|
8567
|
-
|
|
8568
|
-
|
|
9300
|
+
delete routing.pin;
|
|
9301
|
+
routing.hybridHold = true;
|
|
8569
9302
|
this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
|
|
8570
9303
|
continue;
|
|
8571
9304
|
}
|
|
8572
9305
|
let body = parsed.body;
|
|
8573
9306
|
const usedClipboard = body.includes('{{unsent}}');
|
|
8574
9307
|
if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
|
|
8575
|
-
|
|
8576
|
-
|
|
9308
|
+
routing.pin = resolved.channelId;
|
|
9309
|
+
routing.hybridHold = false;
|
|
8577
9310
|
if (!body.trim()) {
|
|
8578
9311
|
console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
|
|
8579
9312
|
continue;
|
|
8580
9313
|
}
|
|
8581
9314
|
try {
|
|
8582
9315
|
const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId);
|
|
8583
|
-
this.recordProseDelivery(
|
|
9316
|
+
this.recordProseDelivery(routing, outcome);
|
|
8584
9317
|
if (outcome?.delivered) {
|
|
8585
9318
|
this.proseBounceStreaks.delete(agent.name);
|
|
8586
9319
|
if (usedClipboard) this.proseClipboards.delete(agent.name);
|
|
8587
9320
|
} else {
|
|
8588
|
-
|
|
8589
|
-
|
|
9321
|
+
delete routing.pin;
|
|
9322
|
+
routing.hybridHold = true;
|
|
8590
9323
|
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
|
|
8591
9324
|
}
|
|
8592
9325
|
} catch (err) {
|
|
8593
|
-
|
|
8594
|
-
|
|
9326
|
+
delete routing.pin;
|
|
9327
|
+
routing.hybridHold = true;
|
|
8595
9328
|
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
|
|
8596
9329
|
}
|
|
8597
9330
|
continue;
|
|
8598
9331
|
}
|
|
8599
9332
|
if (attempted) {
|
|
8600
|
-
|
|
8601
|
-
|
|
9333
|
+
delete routing.pin;
|
|
9334
|
+
routing.hybridHold = true;
|
|
8602
9335
|
this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
|
|
8603
9336
|
continue;
|
|
8604
9337
|
}
|
|
8605
|
-
if (
|
|
8606
|
-
this.recordProseSuppression(
|
|
9338
|
+
if (routing.hybridHold) {
|
|
9339
|
+
this.recordProseSuppression(routing, 1);
|
|
8607
9340
|
continue;
|
|
8608
9341
|
}
|
|
8609
|
-
const sticky =
|
|
9342
|
+
const sticky = routing.pin;
|
|
8610
9343
|
if (!allowLocus && !sticky) {
|
|
8611
|
-
this.recordProseSuppression(
|
|
9344
|
+
this.recordProseSuppression(routing, 1);
|
|
8612
9345
|
continue;
|
|
8613
9346
|
}
|
|
8614
9347
|
const target = sticky ?? locus;
|
|
8615
9348
|
try {
|
|
8616
9349
|
const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
|
|
8617
|
-
this.recordProseDelivery(
|
|
9350
|
+
this.recordProseDelivery(routing, outcome);
|
|
8618
9351
|
} catch (err) {
|
|
8619
9352
|
console.error('hybrid locus delivery failed:', err);
|
|
8620
9353
|
}
|
|
8621
9354
|
}
|
|
8622
9355
|
}
|
|
8623
9356
|
|
|
8624
|
-
private async deliverProseEnvelope(
|
|
9357
|
+
private async deliverProseEnvelope(
|
|
9358
|
+
agent: Agent,
|
|
9359
|
+
envelope: AdmittedEnvelope,
|
|
9360
|
+
round: ProseRound,
|
|
9361
|
+
): Promise<void> {
|
|
8625
9362
|
const name = agent.name;
|
|
9363
|
+
const routing = round.routing;
|
|
9364
|
+
const rawText = envelope.text;
|
|
8626
9365
|
const parsed = parseProsePrefix(rawText);
|
|
8627
|
-
|
|
9366
|
+
// A private round (one that called skip_reply) asks for no publication
|
|
9367
|
+
// or continuation, even when delivery runs after that turn has ended.
|
|
9368
|
+
if (parsed.continueTurn && !round.keepPrivate) routing.logical.continuation = true;
|
|
8628
9369
|
|
|
8629
9370
|
if (parsed.kind === 'private') {
|
|
8630
9371
|
// The text already lives in the assistant message (window/chronicle);
|
|
@@ -8635,20 +9376,35 @@ export class AgentFramework {
|
|
|
8635
9376
|
return;
|
|
8636
9377
|
}
|
|
8637
9378
|
|
|
9379
|
+
if (round.keepPrivate) {
|
|
9380
|
+
// skip_reply's promise: "Any plain text you wrote this turn stays
|
|
9381
|
+
// private and is NOT posted". Every envelope of the round stays in the
|
|
9382
|
+
// context, as a >>skip_reply envelope does: it isn't sent, doesn't
|
|
9383
|
+
// bounce to the clipboard and doesn't move the destination.
|
|
9384
|
+
console.error(
|
|
9385
|
+
`[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` +
|
|
9386
|
+
`${parsed.body.length} chars kept in context (skip_reply round)`,
|
|
9387
|
+
);
|
|
9388
|
+
return;
|
|
9389
|
+
}
|
|
9390
|
+
|
|
8638
9391
|
let targetChannel: string;
|
|
8639
9392
|
let body: string;
|
|
8640
9393
|
if (parsed.kind === 'target') {
|
|
8641
|
-
|
|
9394
|
+
// Resolved once, when the round was admitted (admitProse), never again
|
|
9395
|
+
// here after the wait: a `>>` envelope without its resolution is a bug.
|
|
9396
|
+
const res = envelope.resolved;
|
|
9397
|
+
if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`);
|
|
8642
9398
|
if ('error' in res) {
|
|
8643
9399
|
this.bounceProse(agent, parsed.body, res.error, res.candidates);
|
|
8644
9400
|
return;
|
|
8645
9401
|
}
|
|
8646
9402
|
targetChannel = res.channelId;
|
|
8647
9403
|
// Sticky within the turn: later unprefixed segments follow this target.
|
|
8648
|
-
|
|
9404
|
+
routing.pin = targetChannel;
|
|
8649
9405
|
body = parsed.body;
|
|
8650
9406
|
} else {
|
|
8651
|
-
const sticky =
|
|
9407
|
+
const sticky = routing.pin;
|
|
8652
9408
|
if (!sticky) {
|
|
8653
9409
|
this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
|
|
8654
9410
|
return;
|
|
@@ -8879,8 +9635,8 @@ export class AgentFramework {
|
|
|
8879
9635
|
turnToken: number,
|
|
8880
9636
|
ownsProviderGate: boolean,
|
|
8881
9637
|
): Promise<boolean> {
|
|
8882
|
-
const
|
|
8883
|
-
|
|
9638
|
+
const lifecycle = this.lifecycleGeneration;
|
|
9639
|
+
const continuingTurn = resumesLogicalTurn(trigger?.reason);
|
|
8884
9640
|
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
8885
9641
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
8886
9642
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
@@ -8928,14 +9684,48 @@ export class AgentFramework {
|
|
|
8928
9684
|
// boundary — after the deferred flush (same window: turn alive, compile
|
|
8929
9685
|
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
8930
9686
|
// Not on a context-budget restart: that continues the same logical turn.
|
|
9687
|
+
let assembly: AssemblyResult | undefined;
|
|
8931
9688
|
if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
|
|
8932
9689
|
try {
|
|
8933
|
-
await this.pushCoalescer.assemble(agent.name);
|
|
9690
|
+
assembly = await this.pushCoalescer.assemble(agent.name);
|
|
8934
9691
|
} catch (err) {
|
|
8935
9692
|
console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
|
|
8936
9693
|
}
|
|
8937
9694
|
}
|
|
8938
9695
|
|
|
9696
|
+
// A turn whose only causes were deferred batches, all of which rendered
|
|
9697
|
+
// nothing (empty or blank render, §5.2; cancelled; revoked), has nothing
|
|
9698
|
+
// new to show: running it would wake the model to `[Continue]` or an
|
|
9699
|
+
// older message, an uncaused wake. Stop before the checkpoint, locus,
|
|
9700
|
+
// typing and compile. Any batch this assembly materialized keeps the
|
|
9701
|
+
// turn, including one that was not among its causes (a batch whose wake
|
|
9702
|
+
// the freeze withdrew, or one that never qualified for a wake). A cause
|
|
9703
|
+
// this assembly did not settle (rendered elsewhere, not yet frozen) keeps
|
|
9704
|
+
// the turn, as before.
|
|
9705
|
+
const batchSubjects = trigger?.coalescingBatchSubjects;
|
|
9706
|
+
if (assembly && batchSubjects?.length && assembly.materialized.size === 0
|
|
9707
|
+
&& batchSubjects.every((s) => assembly.settled.has(s))) {
|
|
9708
|
+
console.error(`[coalescing] ${agent.name}: deferred render produced nothing; turn not started (no wake cause left)`);
|
|
9709
|
+
this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: agent.name, subjects: batchSubjects });
|
|
9710
|
+
// Release the turn and land anything deferred while assembly awaited
|
|
9711
|
+
// the render, as a torn-down turn would.
|
|
9712
|
+
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
9713
|
+
this.activeTurnTokens.delete(agent.name);
|
|
9714
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
9715
|
+
}
|
|
9716
|
+
if (!this.activeTurnTokens.has(agent.name)
|
|
9717
|
+
&& this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
9718
|
+
for (const msg of this.drainDeferredFor(agent.name)) {
|
|
9719
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, {
|
|
9720
|
+
deferredWriteId: msg.id,
|
|
9721
|
+
...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
|
|
9722
|
+
});
|
|
9723
|
+
}
|
|
9724
|
+
this.ackDeferredWrites();
|
|
9725
|
+
}
|
|
9726
|
+
return false;
|
|
9727
|
+
}
|
|
9728
|
+
|
|
8939
9729
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
8940
9730
|
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
8941
9731
|
this.recordTurnCheckpoint(agent.name);
|
|
@@ -8990,20 +9780,30 @@ export class AgentFramework {
|
|
|
8990
9780
|
// A framework retry is a new physical stream in the same logical turn.
|
|
8991
9781
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
8992
9782
|
}
|
|
8993
|
-
// Fresh turn: forget the previous turn's explicit-send engagements
|
|
8994
|
-
//
|
|
8995
|
-
// the same logical turn and keeps them).
|
|
9783
|
+
// Fresh turn: forget the previous turn's explicit-send engagements.
|
|
9784
|
+
// A restart continues the same logical turn and keeps them.
|
|
8996
9785
|
this.turnEngagedChannels.delete(agent.name);
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9786
|
+
// A new routing state for the new turn, in every mode: the sticky
|
|
9787
|
+
// destination, the turn's destination, hybrid's hold and the receipt
|
|
9788
|
+
// tallies start empty. The previous turn's object isn't cleared: prose
|
|
9789
|
+
// it left queued still reads and writes its own.
|
|
9790
|
+
const fresh = freshTurnRouting();
|
|
9791
|
+
// A start while the agent's logical turn is open continues it: it
|
|
9792
|
+
// shares the earlier stream's ` !` and receipt tallies, which a
|
|
9793
|
+
// delivery still queued there can write after this stream begins
|
|
9794
|
+
// (openLogical). Otherwise this start opens one. A silent start does
|
|
9795
|
+
// neither: it routes no prose, so it keeps a logical turn of its own,
|
|
9796
|
+
// and an open turn waits for the next start that routes prose.
|
|
9797
|
+
if (!trigger?.suppressProse) {
|
|
9798
|
+
const open = this.openLogical.get(agent.name);
|
|
9799
|
+
if (open) fresh.logical = open;
|
|
9800
|
+
else this.openLogical.set(agent.name, fresh.logical);
|
|
9801
|
+
}
|
|
9802
|
+
this.turnRouting.set(agent.name, fresh);
|
|
9001
9803
|
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
9002
9804
|
// Explicit and disabled prose routing have no inferred locus.
|
|
9003
9805
|
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
9004
9806
|
// prose target at all. Neither mode freezes or announces a locus.
|
|
9005
|
-
this.proseTargetPins.delete(agent.name);
|
|
9006
|
-
this.proseContinuations.delete(agent.name);
|
|
9007
9807
|
this.midTurnInputSignals.delete(agent.name);
|
|
9008
9808
|
this.turnLocusPins.delete(agent.name);
|
|
9009
9809
|
} else {
|
|
@@ -9020,9 +9820,16 @@ export class AgentFramework {
|
|
|
9020
9820
|
// opened/pinned a channel mid-turn.
|
|
9021
9821
|
if (trigger?.suppressProse) {
|
|
9022
9822
|
this.turnLocusPins.delete(agent.name);
|
|
9023
|
-
|
|
9024
|
-
|
|
9823
|
+
// Silent: no routing state. A continuing silent start keeps its turn's
|
|
9824
|
+
// logical turn; a fresh one keeps the one of its own it was given.
|
|
9825
|
+
const silent = freshTurnRouting();
|
|
9826
|
+
const logical = this.turnRouting.get(agent.name)?.logical;
|
|
9827
|
+
if (logical) silent.logical = logical;
|
|
9828
|
+
this.turnRouting.set(agent.name, silent);
|
|
9025
9829
|
}
|
|
9830
|
+
// This start's own logical turn: a failure before its stream exists
|
|
9831
|
+
// settles it, and no other start's.
|
|
9832
|
+
const startLogical = this.turnRouting.get(agent.name)?.logical;
|
|
9026
9833
|
|
|
9027
9834
|
this.touchEphemeralRun(agent.name, true);
|
|
9028
9835
|
this.emitTrace({
|
|
@@ -9057,7 +9864,11 @@ export class AgentFramework {
|
|
|
9057
9864
|
|
|
9058
9865
|
try {
|
|
9059
9866
|
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
9060
|
-
|
|
9867
|
+
// Capture both surfaces together before context hooks can refresh tools.
|
|
9868
|
+
const presentation = this.inspectToolPresentation(agent.name, requestSnapshot);
|
|
9869
|
+
const compressionTools = presentation?.available
|
|
9870
|
+
?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot));
|
|
9871
|
+
const tools = presentation?.advertised ?? compressionTools;
|
|
9061
9872
|
|
|
9062
9873
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
9063
9874
|
// Both produce ContextInjection[] that get merged before inference.
|
|
@@ -9105,9 +9916,10 @@ export class AgentFramework {
|
|
|
9105
9916
|
injections = injections ? [...injections, silentInjection] : [silentInjection];
|
|
9106
9917
|
}
|
|
9107
9918
|
|
|
9108
|
-
// An ephemeral watchdog may dispose this Agent while hooks/compile await
|
|
9109
|
-
//
|
|
9110
|
-
|
|
9919
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await,
|
|
9920
|
+
// and stop() may close provider admission. Do not create a provider
|
|
9921
|
+
// stream after its generation lost ownership, or once admission closed.
|
|
9922
|
+
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
|
|
9111
9923
|
this.channelRegistry?.stopTyping();
|
|
9112
9924
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
9113
9925
|
return false;
|
|
@@ -9129,10 +9941,28 @@ export class AgentFramework {
|
|
|
9129
9941
|
request: compiledRequest,
|
|
9130
9942
|
takeKvSubmission,
|
|
9131
9943
|
drainKvSubmissionIds,
|
|
9132
|
-
} = await agent.startStreamWithInjections(tools, injections
|
|
9133
|
-
|
|
9944
|
+
} = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools, {
|
|
9945
|
+
silentHeartbeat: trigger?.silentHeartbeat,
|
|
9946
|
+
});
|
|
9947
|
+
// The compile can wait for annotations (each batch has one spill
|
|
9948
|
+
// deadline), so check ownership, admission, and the lifecycle again.
|
|
9949
|
+
// A stop followed by start must not revive this earlier turn.
|
|
9950
|
+
// Membrane's yielding stream starts its request when it is first
|
|
9951
|
+
// iterated, so cancelling it here, before driveStream, sends nothing.
|
|
9952
|
+
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
|
|
9953
|
+
console.error(`[turn] ${agent.name}: a turn compiled while ${
|
|
9954
|
+
this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
|
|
9955
|
+
: this.providerAdmissionClosed ? 'the framework was stopping'
|
|
9956
|
+
: 'the framework was stopped and started again'} is dropped before it sends anything`);
|
|
9134
9957
|
stream.cancel();
|
|
9135
9958
|
agent.cancelStream();
|
|
9959
|
+
if (this.agents.get(agent.name) === agent) {
|
|
9960
|
+
// The compile took the agent's stream id, so a frame that started
|
|
9961
|
+
// this one (a guard or budget restart) no longer owns the entries
|
|
9962
|
+
// it left, and its teardown leaves them to this frame.
|
|
9963
|
+
this.activeStreams.delete(agent.name);
|
|
9964
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
9965
|
+
}
|
|
9136
9966
|
this.channelRegistry?.stopTyping();
|
|
9137
9967
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
9138
9968
|
return false;
|
|
@@ -9195,7 +10025,7 @@ export class AgentFramework {
|
|
|
9195
10025
|
stopReason: 'exhausted',
|
|
9196
10026
|
speech: '',
|
|
9197
10027
|
error: err.message,
|
|
9198
|
-
});
|
|
10028
|
+
}, startLogical);
|
|
9199
10029
|
this.emitTrace({
|
|
9200
10030
|
type: 'inference:exhausted',
|
|
9201
10031
|
agentName: agent.name,
|
|
@@ -9259,6 +10089,9 @@ export class AgentFramework {
|
|
|
9259
10089
|
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
9260
10090
|
// means no successor turn can have replaced it while we compiled.
|
|
9261
10091
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
10092
|
+
// The turn-alive guard also keeps this turn's routing current at entry.
|
|
10093
|
+
// Retain it for queued deliveries and receipts after a tool ends the turn.
|
|
10094
|
+
const streamRouting = this.routingOf(agent.name);
|
|
9262
10095
|
let hadToolCalls = false;
|
|
9263
10096
|
let generationLost = false;
|
|
9264
10097
|
|
|
@@ -9280,14 +10113,26 @@ export class AgentFramework {
|
|
|
9280
10113
|
// awaits the chain before routing trailing prose, so in-channel ordering
|
|
9281
10114
|
// is preserved end-to-end. Each link catches its own error: one failed
|
|
9282
10115
|
// post must not silence the rest of the turn.
|
|
9283
|
-
|
|
10116
|
+
//
|
|
10117
|
+
// Every stream queues its deliveries behind the agent's latest link, so
|
|
10118
|
+
// the agent's posts go out in the order they were written, across a
|
|
10119
|
+
// turn's streams (a budget restart, a tool-result-guard retry) and across
|
|
10120
|
+
// turns. A turn that a tool ends (skip_reply) settles before its queued
|
|
10121
|
+
// prose has posted; that prose keeps its own turn's routing state
|
|
10122
|
+
// (turnRouting), and the next turn's posts wait behind it.
|
|
10123
|
+
const resumesTurn = resumesLogicalTurn(trigger?.reason);
|
|
10124
|
+
let turnSpeechChain: Promise<void> = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
|
|
10125
|
+
this.proseSpeechChains.set(agent.name, turnSpeechChain);
|
|
10126
|
+
const chainSpeech = (link: () => Promise<void>, failure: string): void => {
|
|
10127
|
+
turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err));
|
|
10128
|
+
// A stream another has replaced doesn't move the turn's latest link.
|
|
10129
|
+
if (agent.streamId === myStreamId) this.proseSpeechChains.set(agent.name, turnSpeechChain);
|
|
10130
|
+
};
|
|
9284
10131
|
const enqueueSpeech = (text: string, locus: string | null): void => {
|
|
9285
|
-
|
|
9286
|
-
.
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
})
|
|
9290
|
-
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
10132
|
+
chainSpeech(async () => {
|
|
10133
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
|
|
10134
|
+
this.recordProseDelivery(streamRouting, outcome);
|
|
10135
|
+
}, 'mid-turn speech routing failed:');
|
|
9291
10136
|
};
|
|
9292
10137
|
|
|
9293
10138
|
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
@@ -9366,7 +10211,16 @@ export class AgentFramework {
|
|
|
9366
10211
|
!agent.toolWrapperProseGuard
|
|
9367
10212
|
? new ProseStreamRouter({
|
|
9368
10213
|
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
9369
|
-
|
|
10214
|
+
// Explicit text has no destination until a `>>` prefix names one,
|
|
10215
|
+
// so a fresh turn's stream starts suppressed. One that resumes the
|
|
10216
|
+
// turn (a tool-result-guard retry, a budget restart) starts at the
|
|
10217
|
+
// destination the turn's delivered rounds have set: a private
|
|
10218
|
+
// round's prefixes don't count, and the gateway's queued
|
|
10219
|
+
// deliveries needn't have run. The trigger channel is only where
|
|
10220
|
+
// explicit mode's typing indicator shows.
|
|
10221
|
+
initialTarget: turnProseRouting === 'explicit'
|
|
10222
|
+
? (resumesTurn ? streamRouting.destination : null)
|
|
10223
|
+
: typingChannel,
|
|
9370
10224
|
resolve: (spec) => {
|
|
9371
10225
|
const r = this.channelRegistry!.resolveProseTarget(spec);
|
|
9372
10226
|
return 'channelId' in r ? r.channelId : null;
|
|
@@ -9388,6 +10242,8 @@ export class AgentFramework {
|
|
|
9388
10242
|
// other trace consumers voice it). Hold both; release in order on a clean
|
|
9389
10243
|
// round (tool-calls / non-guard completion), discard on a guard refusal.
|
|
9390
10244
|
let guardHeld: Array<{ trace: Parameters<AgentFramework['emitTrace']>[0]; text?: string }> = [];
|
|
10245
|
+
// The text block the stream's last text came from, within this round.
|
|
10246
|
+
let lastTextBlock: number | undefined;
|
|
9391
10247
|
const releaseGuardHeld = (): void => {
|
|
9392
10248
|
const held = guardHeld;
|
|
9393
10249
|
guardHeld = [];
|
|
@@ -9432,7 +10288,17 @@ export class AgentFramework {
|
|
|
9432
10288
|
// turn's prose is bound for, without re-deriving routing.
|
|
9433
10289
|
channelId: typingChannel ?? undefined,
|
|
9434
10290
|
};
|
|
9435
|
-
|
|
10291
|
+
// Delivery joins a round's text blocks with a newline
|
|
10292
|
+
// (splitProseSegments), so a later block starts a new line for
|
|
10293
|
+
// the stream too.
|
|
10294
|
+
let text = event.meta.type === 'text' ? event.content : undefined;
|
|
10295
|
+
if (text !== undefined) {
|
|
10296
|
+
const block = event.meta.blockIndex;
|
|
10297
|
+
if (block !== undefined) {
|
|
10298
|
+
if (lastTextBlock !== undefined && block !== lastTextBlock) text = '\n' + text;
|
|
10299
|
+
lastTextBlock = block;
|
|
10300
|
+
}
|
|
10301
|
+
}
|
|
9436
10302
|
if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
|
|
9437
10303
|
guardHeld.push({ trace, text });
|
|
9438
10304
|
} else {
|
|
@@ -9465,6 +10331,11 @@ export class AgentFramework {
|
|
|
9465
10331
|
outgoingIndex = 0;
|
|
9466
10332
|
discardGuardHeld();
|
|
9467
10333
|
proseStream?.reset();
|
|
10334
|
+
lastTextBlock = undefined;
|
|
10335
|
+
// Explicit mode's retried attempt starts where the turn's
|
|
10336
|
+
// delivered rounds have set the destination, not at the stream's
|
|
10337
|
+
// seed: rounds this stream delivered before the refusal stand.
|
|
10338
|
+
if (turnProseRouting === 'explicit') proseStream?.retarget(streamRouting.destination);
|
|
9468
10339
|
this.emitTrace({
|
|
9469
10340
|
type: 'inference:tokens',
|
|
9470
10341
|
agentName: agent.name,
|
|
@@ -9494,6 +10365,10 @@ export class AgentFramework {
|
|
|
9494
10365
|
// preceding results before storing/dispatching this new round.
|
|
9495
10366
|
agent.toolResultGuard.accept();
|
|
9496
10367
|
releaseGuardHeld();
|
|
10368
|
+
// The round's text ends here: delivery parses each round's text
|
|
10369
|
+
// on its own, so the next round's text starts a new line.
|
|
10370
|
+
if (proseStream) emitOutgoing(proseStream.endSegment());
|
|
10371
|
+
lastTextBlock = undefined;
|
|
9497
10372
|
adoptInjectedRound();
|
|
9498
10373
|
hadToolCalls = true;
|
|
9499
10374
|
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
@@ -9602,39 +10477,58 @@ export class AgentFramework {
|
|
|
9602
10477
|
console.error(
|
|
9603
10478
|
`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
|
|
9604
10479
|
);
|
|
9605
|
-
this.recordProseSuppression(
|
|
10480
|
+
this.recordProseSuppression(streamRouting, roundSegments.length);
|
|
9606
10481
|
} else if (turnProseRouting === 'hybrid') {
|
|
9607
10482
|
if (turnSilenced) {
|
|
9608
|
-
this.recordProseSuppression(
|
|
10483
|
+
this.recordProseSuppression(streamRouting, roundSegments.length);
|
|
9609
10484
|
} else if (!hasSameRoundPrivateThink) {
|
|
9610
10485
|
const locus = resolveTurnLocus();
|
|
10486
|
+
// Queued under this turn's routing, whenever it posts.
|
|
10487
|
+
const routing = streamRouting;
|
|
9611
10488
|
for (const seg of roundSegments) {
|
|
9612
|
-
|
|
9613
|
-
.then(() => this.deliverHybridProse(agent, seg, locus, true))
|
|
9614
|
-
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
10489
|
+
chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true, routing), 'mid-turn hybrid prose delivery failed:');
|
|
9615
10490
|
}
|
|
9616
10491
|
}
|
|
9617
10492
|
} else if (turnProseRouting === 'explicit') {
|
|
9618
|
-
// Explicit mode:
|
|
9619
|
-
//
|
|
9620
|
-
//
|
|
10493
|
+
// Explicit mode: the segments go through the prose
|
|
10494
|
+
// gateway, unless a private think in the round holds them
|
|
10495
|
+
// back. Send silencing does not apply: unprefixed prose
|
|
10496
|
+
// bounces, and prefixed prose is deliberate. skip_reply
|
|
10497
|
+
// does: its round's envelopes all stay in the context
|
|
10498
|
+
// (deliverProseEnvelope's keepPrivate), as the tool
|
|
10499
|
+
// promises, and skip_reply then ends the turn at this
|
|
10500
|
+
// round's tool results.
|
|
9621
10501
|
if (!hasSameRoundPrivateThink) {
|
|
10502
|
+
const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply');
|
|
10503
|
+
// The round is admitted now: its targets are resolved
|
|
10504
|
+
// once, for the turn's destination and for its queued
|
|
10505
|
+
// delivery alike.
|
|
10506
|
+
const admitted = roundSegments.map((seg) => this.admitProse(seg));
|
|
10507
|
+
// Queued under this turn's routing, whenever it posts.
|
|
10508
|
+
const routing = streamRouting;
|
|
10509
|
+
if (!keepPrivate) {
|
|
10510
|
+
routing.destination = this.roundDestination(routing.destination, admitted.flat());
|
|
10511
|
+
}
|
|
9622
10512
|
console.error(
|
|
9623
|
-
`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`,
|
|
10513
|
+
`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`,
|
|
9624
10514
|
);
|
|
9625
|
-
for (const
|
|
9626
|
-
|
|
9627
|
-
.then(() => this.deliverProse(agent, seg))
|
|
9628
|
-
.catch((err) => console.error('mid-turn prose delivery failed:', err));
|
|
10515
|
+
for (const envelopes of admitted) {
|
|
10516
|
+
chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate, routing }), 'mid-turn prose delivery failed:');
|
|
9629
10517
|
}
|
|
9630
10518
|
}
|
|
10519
|
+
// The stream's next round starts where delivery sends
|
|
10520
|
+
// unprefixed text: the turn's destination. The stream
|
|
10521
|
+
// followed this round's prefixes as it was written, but a
|
|
10522
|
+
// private round's, a >>skip_reply envelope's or a target
|
|
10523
|
+
// that didn't resolve leave the destination as it was.
|
|
10524
|
+
proseStream?.retarget(streamRouting.destination);
|
|
9631
10525
|
} else if (turnSilenced) {
|
|
9632
10526
|
console.error(
|
|
9633
10527
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
|
|
9634
10528
|
);
|
|
9635
10529
|
// Visible in the turn-end receipt — silencing must never
|
|
9636
10530
|
// be a silent black hole (n=8: the flying-scene reply).
|
|
9637
|
-
this.recordProseSuppression(
|
|
10531
|
+
this.recordProseSuppression(streamRouting, roundSegments.length);
|
|
9638
10532
|
} else if (hasSameRoundPrivateThink) {
|
|
9639
10533
|
console.error(
|
|
9640
10534
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
|
|
@@ -9676,6 +10570,7 @@ export class AgentFramework {
|
|
|
9676
10570
|
// persisted as assistant speech, including a terminal refusal
|
|
9677
10571
|
// after the single recovery retry.
|
|
9678
10572
|
proseStream?.reset();
|
|
10573
|
+
if (turnProseRouting === 'explicit') proseStream?.retarget(streamRouting.destination);
|
|
9679
10574
|
if (withheld) {
|
|
9680
10575
|
const usage = response.details?.usage ?? response.usage;
|
|
9681
10576
|
const tokenUsage = usage ? {
|
|
@@ -9758,7 +10653,7 @@ export class AgentFramework {
|
|
|
9758
10653
|
// Flush pending assistant blocks
|
|
9759
10654
|
const pending = this.pendingAssistantBlocks.get(agent.name);
|
|
9760
10655
|
if (pending) {
|
|
9761
|
-
agent.addAssistantResponse(pending);
|
|
10656
|
+
agent.addAssistantResponse(pending, this.silentTurnRowMetadata(agent.name));
|
|
9762
10657
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
9763
10658
|
}
|
|
9764
10659
|
// Store tool results — same bounded spill policy as the
|
|
@@ -9773,7 +10668,8 @@ export class AgentFramework {
|
|
|
9773
10668
|
cap,
|
|
9774
10669
|
);
|
|
9775
10670
|
agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
|
|
9776
|
-
this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults
|
|
10671
|
+
this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults,
|
|
10672
|
+
this.silentTurnRowMetadata(agent.name));
|
|
9777
10673
|
// The response is already complete: this batch is never
|
|
9778
10674
|
// submitted in this turn, so it cannot be refused. Admit it.
|
|
9779
10675
|
agent.toolResultGuard.settleTurnEnded();
|
|
@@ -9817,16 +10713,17 @@ export class AgentFramework {
|
|
|
9817
10713
|
system: true,
|
|
9818
10714
|
kind: 'tool-wrapper-prose-contained',
|
|
9819
10715
|
toolName: guardedWrapperTool,
|
|
10716
|
+
...this.silentTurnRowMetadata(agent.name),
|
|
9820
10717
|
});
|
|
9821
10718
|
console.error(
|
|
9822
10719
|
`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`,
|
|
9823
10720
|
);
|
|
9824
10721
|
} else if (lastToolIdx >= 0) {
|
|
9825
10722
|
if (terminalContent.length > 0) {
|
|
9826
|
-
agent.addAssistantResponse(terminalContent);
|
|
10723
|
+
agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
|
|
9827
10724
|
}
|
|
9828
10725
|
} else {
|
|
9829
|
-
agent.addAssistantResponse(terminalContent);
|
|
10726
|
+
agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
|
|
9830
10727
|
}
|
|
9831
10728
|
|
|
9832
10729
|
// Bind the cooldown receipt to this fresh, successful request —
|
|
@@ -9854,6 +10751,20 @@ export class AgentFramework {
|
|
|
9854
10751
|
const speechContent = hadToolCalls ? [] : allText;
|
|
9855
10752
|
const thoughts = hadToolCalls ? allText : [];
|
|
9856
10753
|
|
|
10754
|
+
// Explicit text is admitted as its round ends, before anything
|
|
10755
|
+
// below waits (a module's speech callback, the turn's earlier
|
|
10756
|
+
// deliveries): its targets resolve now, as a live round's do.
|
|
10757
|
+
const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim();
|
|
10758
|
+
const finalSegments = hadToolCalls && allText.length > 0
|
|
10759
|
+
? splitProseSegments(liveProseRouting ? terminalContent : response.content)
|
|
10760
|
+
: [];
|
|
10761
|
+
const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry;
|
|
10762
|
+
// The final text is delivered under this turn's routing, as its
|
|
10763
|
+
// queued rounds are.
|
|
10764
|
+
const finalRouting = streamRouting;
|
|
10765
|
+
const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null;
|
|
10766
|
+
const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : [];
|
|
10767
|
+
|
|
9857
10768
|
const du = response.details?.usage;
|
|
9858
10769
|
const tokenUsage = du
|
|
9859
10770
|
? {
|
|
@@ -9888,7 +10799,7 @@ export class AgentFramework {
|
|
|
9888
10799
|
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
9889
10800
|
.map((block) => block.text)
|
|
9890
10801
|
.join('\n'),
|
|
9891
|
-
});
|
|
10802
|
+
}, streamRouting.logical);
|
|
9892
10803
|
|
|
9893
10804
|
this.emitTrace({
|
|
9894
10805
|
type: 'inference:completed',
|
|
@@ -10066,33 +10977,35 @@ export class AgentFramework {
|
|
|
10066
10977
|
// they are never routed here — which is precisely how the `think`
|
|
10067
10978
|
// tool (and any explicit send tool) yields a silent turn.
|
|
10068
10979
|
if (speechContent.length > 0 && this.channelRegistry) {
|
|
10069
|
-
const speechText =
|
|
10070
|
-
.map((b) => (b as ContentBlock & { type: 'text' }).text)
|
|
10071
|
-
.join('\n')
|
|
10072
|
-
.trim();
|
|
10980
|
+
const speechText = finalSpeechText;
|
|
10073
10981
|
if (speechText) {
|
|
10982
|
+
// After the agent's earlier deliveries: every stream's chain
|
|
10983
|
+
// starts from the agent's latest link, so this waits for the
|
|
10984
|
+
// turn's earlier rounds and for an earlier turn's posts still
|
|
10985
|
+
// queued.
|
|
10986
|
+
await turnSpeechChain;
|
|
10074
10987
|
if (turnProseRouting === 'disabled') {
|
|
10075
10988
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
10076
|
-
this.recordProseSuppression(
|
|
10989
|
+
this.recordProseSuppression(streamRouting, 1);
|
|
10077
10990
|
} else if (turnSilenced && turnProseRouting !== 'explicit') {
|
|
10078
10991
|
// Only reachable on a guard recovery that carried the
|
|
10079
10992
|
// same-turn send suppression, or a suppressProse trigger
|
|
10080
10993
|
// (a fresh text-only turn starts unsilenced). Explicit
|
|
10081
10994
|
// mode is exempt, as mid-turn.
|
|
10082
10995
|
console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
|
|
10083
|
-
this.recordProseSuppression(
|
|
10996
|
+
this.recordProseSuppression(streamRouting, 1);
|
|
10084
10997
|
} else if (turnProseRouting === 'hybrid') {
|
|
10085
10998
|
const locus = resolveTurnLocus();
|
|
10086
10999
|
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
10087
11000
|
try {
|
|
10088
|
-
await this.deliverHybridProse(agent, speechText, locus, true);
|
|
11001
|
+
await this.deliverHybridProse(agent, speechText, locus, true, finalRouting);
|
|
10089
11002
|
} catch (err) {
|
|
10090
11003
|
console.error('text-only hybrid prose delivery failed:', err);
|
|
10091
11004
|
}
|
|
10092
11005
|
} else if (turnProseRouting === 'explicit') {
|
|
10093
11006
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
10094
11007
|
try {
|
|
10095
|
-
await this.
|
|
11008
|
+
await this.deliverAdmittedProse(agent, admittedSpeech!, { routing: finalRouting });
|
|
10096
11009
|
} catch (err) {
|
|
10097
11010
|
console.error('text-only prose delivery failed:', err);
|
|
10098
11011
|
}
|
|
@@ -10109,7 +11022,7 @@ export class AgentFramework {
|
|
|
10109
11022
|
);
|
|
10110
11023
|
try {
|
|
10111
11024
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
10112
|
-
this.recordProseDelivery(
|
|
11025
|
+
this.recordProseDelivery(streamRouting, outcome);
|
|
10113
11026
|
} catch (err) {
|
|
10114
11027
|
console.error('speech routing failed:', err);
|
|
10115
11028
|
}
|
|
@@ -10138,7 +11051,7 @@ export class AgentFramework {
|
|
|
10138
11051
|
? turnSilenced
|
|
10139
11052
|
: turnSilenced || toolNames.some(isSilencingTool);
|
|
10140
11053
|
|
|
10141
|
-
const segments =
|
|
11054
|
+
const segments = finalSegments;
|
|
10142
11055
|
|
|
10143
11056
|
// Preserve in-channel ordering: everything enqueued live must
|
|
10144
11057
|
// land before the trailing prose. Awaited even when silenced —
|
|
@@ -10150,16 +11063,16 @@ export class AgentFramework {
|
|
|
10150
11063
|
console.error(
|
|
10151
11064
|
`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
|
|
10152
11065
|
);
|
|
10153
|
-
this.recordProseSuppression(
|
|
11066
|
+
this.recordProseSuppression(streamRouting, segments.length);
|
|
10154
11067
|
}
|
|
10155
11068
|
} else if (turnProseRouting === 'hybrid') {
|
|
10156
11069
|
if (silenced && segments.length > 0) {
|
|
10157
|
-
this.recordProseSuppression(
|
|
11070
|
+
this.recordProseSuppression(streamRouting, segments.length);
|
|
10158
11071
|
} else if (segments.length > 0) {
|
|
10159
11072
|
const locus = resolveTurnLocus();
|
|
10160
11073
|
for (const seg of segments) {
|
|
10161
11074
|
try {
|
|
10162
|
-
await this.deliverHybridProse(agent, seg, locus, true);
|
|
11075
|
+
await this.deliverHybridProse(agent, seg, locus, true, finalRouting);
|
|
10163
11076
|
} catch (err) {
|
|
10164
11077
|
console.error('trailing hybrid prose delivery failed:', err);
|
|
10165
11078
|
}
|
|
@@ -10170,9 +11083,9 @@ export class AgentFramework {
|
|
|
10170
11083
|
console.error(
|
|
10171
11084
|
`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
|
|
10172
11085
|
);
|
|
10173
|
-
for (const
|
|
11086
|
+
for (const envelopes of admittedSegments) {
|
|
10174
11087
|
try {
|
|
10175
|
-
await this.
|
|
11088
|
+
await this.deliverAdmittedProse(agent, envelopes, { routing: finalRouting });
|
|
10176
11089
|
} catch (err) {
|
|
10177
11090
|
console.error('trailing prose delivery failed:', err);
|
|
10178
11091
|
}
|
|
@@ -10184,7 +11097,7 @@ export class AgentFramework {
|
|
|
10184
11097
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
|
|
10185
11098
|
);
|
|
10186
11099
|
if (silenced && segments.length > 0) {
|
|
10187
|
-
this.recordProseSuppression(
|
|
11100
|
+
this.recordProseSuppression(streamRouting, segments.length);
|
|
10188
11101
|
}
|
|
10189
11102
|
} else {
|
|
10190
11103
|
// Reuse the locus pinned at the turn's first live-routed
|
|
@@ -10202,7 +11115,7 @@ export class AgentFramework {
|
|
|
10202
11115
|
for (const seg of segments) {
|
|
10203
11116
|
try {
|
|
10204
11117
|
const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
10205
|
-
this.recordProseDelivery(
|
|
11118
|
+
this.recordProseDelivery(streamRouting, outcome);
|
|
10206
11119
|
} catch (err) {
|
|
10207
11120
|
console.error('speech routing failed:', err);
|
|
10208
11121
|
}
|
|
@@ -10214,28 +11127,21 @@ export class AgentFramework {
|
|
|
10214
11127
|
// doesn't depend on the status field, so it correctly runs after.
|
|
10215
11128
|
|
|
10216
11129
|
// Logical turn end (normal completion): drop the `[delivered]`
|
|
10217
|
-
// receipt
|
|
10218
|
-
// awaited
|
|
10219
|
-
//
|
|
10220
|
-
//
|
|
11130
|
+
// receipt, once every delivery has settled. The text-only and
|
|
11131
|
+
// trailing paths awaited the chain when there was final text; a
|
|
11132
|
+
// turn whose last response is empty awaits it here, so the
|
|
11133
|
+
// receipt and the continuation see its last rounds' deliveries.
|
|
11134
|
+
// Locus and hybrid mode; explicit-mode envelopes acknowledge
|
|
11135
|
+
// themselves through the prose gateway.
|
|
11136
|
+
await turnSpeechChain;
|
|
10221
11137
|
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
10222
|
-
this.appendProseDeliveryReceipt(agent);
|
|
11138
|
+
this.appendProseDeliveryReceipt(agent, streamRouting);
|
|
10223
11139
|
}
|
|
10224
11140
|
|
|
10225
|
-
//
|
|
10226
|
-
//
|
|
10227
|
-
// now instead of pausing until the next external event. This gives
|
|
10228
|
-
// prose the same continuation ability tool rounds have (wanted for
|
|
11141
|
+
// A `!` continuation this turn asked for. This gives prose the
|
|
11142
|
+
// same continuation ability tool rounds have (wanted for
|
|
10229
11143
|
// robotics-style loops where an end-of-turn pause is harmful).
|
|
10230
|
-
|
|
10231
|
-
console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
|
|
10232
|
-
this.pendingRequests.push({
|
|
10233
|
-
agentName: agent.name,
|
|
10234
|
-
reason: 'prose-continuation',
|
|
10235
|
-
source: 'framework',
|
|
10236
|
-
timestamp: Date.now(),
|
|
10237
|
-
});
|
|
10238
|
-
}
|
|
11144
|
+
this.requestProseContinuation(agent, finalRouting);
|
|
10239
11145
|
|
|
10240
11146
|
break;
|
|
10241
11147
|
}
|
|
@@ -10287,7 +11193,7 @@ export class AgentFramework {
|
|
|
10287
11193
|
stopReason: 'exhausted',
|
|
10288
11194
|
speech: '',
|
|
10289
11195
|
error: 'Turn abandoned by operator quiesce',
|
|
10290
|
-
});
|
|
11196
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
|
|
10291
11197
|
this.emitTrace({
|
|
10292
11198
|
type: 'inference:aborted',
|
|
10293
11199
|
agentName: agent.name,
|
|
@@ -10316,7 +11222,7 @@ export class AgentFramework {
|
|
|
10316
11222
|
stopReason: 'exhausted',
|
|
10317
11223
|
speech: '',
|
|
10318
11224
|
error: err.message,
|
|
10319
|
-
});
|
|
11225
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
|
|
10320
11226
|
this.emitTrace({
|
|
10321
11227
|
type: 'inference:exhausted',
|
|
10322
11228
|
agentName: agent.name,
|
|
@@ -10370,7 +11276,7 @@ export class AgentFramework {
|
|
|
10370
11276
|
stopReason: 'exhausted',
|
|
10371
11277
|
speech: '',
|
|
10372
11278
|
error: 'Turn abandoned by operator quiesce',
|
|
10373
|
-
});
|
|
11279
|
+
}, streamRouting.logical);
|
|
10374
11280
|
}
|
|
10375
11281
|
this.emitTrace({
|
|
10376
11282
|
type: 'inference:aborted',
|
|
@@ -10383,13 +11289,22 @@ export class AgentFramework {
|
|
|
10383
11289
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
10384
11290
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
10385
11291
|
// so settle the delivery chain and drop the receipt. A
|
|
10386
|
-
// budget restart
|
|
10387
|
-
//
|
|
10388
|
-
// start) and the continuation stream's end writes ONE
|
|
10389
|
-
// receipt for the whole turn.
|
|
11292
|
+
// budget restart keeps the same turn's routing and tallies;
|
|
11293
|
+
// the continuation stream's end writes one receipt for it.
|
|
10390
11294
|
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
|
|
10391
11295
|
await turnSpeechChain;
|
|
10392
|
-
this.appendProseDeliveryReceipt(agent);
|
|
11296
|
+
this.appendProseDeliveryReceipt(agent, streamRouting);
|
|
11297
|
+
}
|
|
11298
|
+
// A `!` an earlier round asked for still starts its
|
|
11299
|
+
// continuation when a tool ends the turn, once the turn's
|
|
11300
|
+
// deliveries have run. Explicit mode doesn't hold the turn
|
|
11301
|
+
// for them, so it checks at the end of its chain.
|
|
11302
|
+
if (cancelKind === 'turn_ended') {
|
|
11303
|
+
if (turnProseRouting === 'explicit') {
|
|
11304
|
+
void turnSpeechChain.then(() => this.requestProseContinuation(agent, streamRouting));
|
|
11305
|
+
} else {
|
|
11306
|
+
this.requestProseContinuation(agent, streamRouting);
|
|
11307
|
+
}
|
|
10393
11308
|
}
|
|
10394
11309
|
return;
|
|
10395
11310
|
}
|
|
@@ -10405,7 +11320,7 @@ export class AgentFramework {
|
|
|
10405
11320
|
stopReason: 'exhausted',
|
|
10406
11321
|
speech: '',
|
|
10407
11322
|
error: `Stream aborted: ${reason}`,
|
|
10408
|
-
});
|
|
11323
|
+
}, streamRouting.logical);
|
|
10409
11324
|
this.emitTrace({
|
|
10410
11325
|
type: 'inference:exhausted',
|
|
10411
11326
|
agentName: agent.name,
|
|
@@ -10551,7 +11466,7 @@ export class AgentFramework {
|
|
|
10551
11466
|
stopReason: 'exhausted',
|
|
10552
11467
|
speech: '',
|
|
10553
11468
|
error: err.message,
|
|
10554
|
-
});
|
|
11469
|
+
}, streamRouting.logical);
|
|
10555
11470
|
this.emitTrace({
|
|
10556
11471
|
type: 'inference:exhausted',
|
|
10557
11472
|
agentName: agent.name,
|
|
@@ -10815,13 +11730,15 @@ export class AgentFramework {
|
|
|
10815
11730
|
let ownedToEnd = false;
|
|
10816
11731
|
try {
|
|
10817
11732
|
const started = Date.now();
|
|
10818
|
-
const
|
|
10819
|
-
|
|
10820
|
-
name: toolName,
|
|
10821
|
-
input,
|
|
10822
|
-
callerAgentName: agentName,
|
|
10823
|
-
});
|
|
11733
|
+
const call = { id: toolUseId, name: toolName, input, callerAgentName: agentName };
|
|
11734
|
+
const returned = await this.executeToolCall(call);
|
|
10824
11735
|
const durationMs = Date.now() - started;
|
|
11736
|
+
// The pair is stored as a model's call would be, so the result stored,
|
|
11737
|
+
// traced and logged is its checked form. The caller gets the tool's
|
|
11738
|
+
// own result back, as an in-process caller does, unless nothing
|
|
11739
|
+
// usable could be stored: then it gets the error result stored.
|
|
11740
|
+
const checked = checkedToolResult(call, returned);
|
|
11741
|
+
const result = checked.result;
|
|
10825
11742
|
|
|
10826
11743
|
// Store the pair through the same shapes the ordinary path uses. Build
|
|
10827
11744
|
// the result blocks BEFORE storing the tool_use: the spill path awaits.
|
|
@@ -10869,7 +11786,7 @@ export class AgentFramework {
|
|
|
10869
11786
|
`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
|
|
10870
11787
|
`in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`,
|
|
10871
11788
|
);
|
|
10872
|
-
return { toolUseId, result };
|
|
11789
|
+
return { toolUseId, result: checked.usable ? returned : result };
|
|
10873
11790
|
} finally {
|
|
10874
11791
|
if (this.activeTurnTokens.get(agentName) === turnToken) this.activeTurnTokens.delete(agentName);
|
|
10875
11792
|
// Messages deferred while we held the token land now, after the pair
|
|
@@ -10893,6 +11810,7 @@ export class AgentFramework {
|
|
|
10893
11810
|
}
|
|
10894
11811
|
|
|
10895
11812
|
private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
|
|
11813
|
+
if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
|
|
10896
11814
|
// Client-side programmatic tool calling for promise-based callers
|
|
10897
11815
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
10898
11816
|
// gets its own interpreter state.
|
|
@@ -10931,10 +11849,11 @@ export class AgentFramework {
|
|
|
10931
11849
|
// ephemerals, ModuleContext.callTool) reach utilities the same way the
|
|
10932
11850
|
// model does.
|
|
10933
11851
|
if (call.name === 'utils') {
|
|
10934
|
-
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
|
|
11852
|
+
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true });
|
|
10935
11853
|
}
|
|
10936
11854
|
|
|
10937
|
-
// Module tools
|
|
11855
|
+
// Module tools. The caller uses the result in process, so it gets the
|
|
11856
|
+
// module's own result, as the module returned it.
|
|
10938
11857
|
return this.moduleRegistry.handleToolCall(call);
|
|
10939
11858
|
}
|
|
10940
11859
|
|
|
@@ -11013,6 +11932,7 @@ export class AgentFramework {
|
|
|
11013
11932
|
this.pushEvent({
|
|
11014
11933
|
type: 'tool-result',
|
|
11015
11934
|
callId: call.id,
|
|
11935
|
+
toolName: call.name,
|
|
11016
11936
|
agentName,
|
|
11017
11937
|
moduleName: 'code_execution',
|
|
11018
11938
|
result,
|
|
@@ -11031,6 +11951,11 @@ export class AgentFramework {
|
|
|
11031
11951
|
* results reach the running script and never the model context.
|
|
11032
11952
|
*/
|
|
11033
11953
|
private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
|
|
11954
|
+
// RFC-007: what the script's own tool calls are attributed to. Read
|
|
11955
|
+
// before the first await: it is only known while this call is being
|
|
11956
|
+
// dispatched. Undefined off the model dispatch path (ephemeral callers),
|
|
11957
|
+
// whose code_execution call is not reported either.
|
|
11958
|
+
const origin = this.toolLifecycleEmitter?.scriptOrigin(agentName, call.id);
|
|
11034
11959
|
const input = (call.input ?? {}) as {
|
|
11035
11960
|
code?: unknown;
|
|
11036
11961
|
background?: unknown;
|
|
@@ -11106,7 +12031,7 @@ export class AgentFramework {
|
|
|
11106
12031
|
);
|
|
11107
12032
|
|
|
11108
12033
|
if (input.background === true) {
|
|
11109
|
-
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
|
|
12034
|
+
const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs, origin);
|
|
11110
12035
|
if (capNote && started.success && started.data && typeof started.data === 'object') {
|
|
11111
12036
|
(started.data as Record<string, unknown>).time_limit_note = capNote;
|
|
11112
12037
|
}
|
|
@@ -11114,9 +12039,23 @@ export class AgentFramework {
|
|
|
11114
12039
|
}
|
|
11115
12040
|
|
|
11116
12041
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
11117
|
-
this
|
|
11118
|
-
|
|
11119
|
-
const
|
|
12042
|
+
// A busy runner refuses this exec; the running script keeps its mark and
|
|
12043
|
+
// its origin.
|
|
12044
|
+
const ownsRunner = !runner.busy;
|
|
12045
|
+
const script = { endTurn: false };
|
|
12046
|
+
if (ownsRunner) this.scriptDeferredEndTurn.set(agentName, script);
|
|
12047
|
+
const ownsOrigin = origin !== undefined && ownsRunner;
|
|
12048
|
+
if (ownsOrigin) this.foregroundScriptOrigins.set(agentName, origin);
|
|
12049
|
+
let exec: import('./code-execution/py-runner.js').ExecResult;
|
|
12050
|
+
try {
|
|
12051
|
+
exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
|
|
12052
|
+
} finally {
|
|
12053
|
+
if (this.scriptDeferredEndTurn.get(agentName) === script) this.scriptDeferredEndTurn.delete(agentName);
|
|
12054
|
+
if (ownsOrigin && this.foregroundScriptOrigins.get(agentName) === origin) {
|
|
12055
|
+
this.foregroundScriptOrigins.delete(agentName);
|
|
12056
|
+
}
|
|
12057
|
+
}
|
|
12058
|
+
const endTurn = script.endTurn;
|
|
11120
12059
|
|
|
11121
12060
|
return {
|
|
11122
12061
|
success: true,
|
|
@@ -11148,6 +12087,7 @@ export class AgentFramework {
|
|
|
11148
12087
|
code: string,
|
|
11149
12088
|
injected: import('./code-execution/py-runner.js').InjectedTool[],
|
|
11150
12089
|
timeLimitMs?: number,
|
|
12090
|
+
origin?: ScriptCallOrigin,
|
|
11151
12091
|
): ToolResult {
|
|
11152
12092
|
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
11153
12093
|
// would outlive its owner and its wake would land in the primary
|
|
@@ -11197,7 +12137,7 @@ export class AgentFramework {
|
|
|
11197
12137
|
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
11198
12138
|
idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
|
|
11199
12139
|
label: `${agentName}:${scriptId}`,
|
|
11200
|
-
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
|
|
12140
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, origin),
|
|
11201
12141
|
});
|
|
11202
12142
|
|
|
11203
12143
|
const record: BackgroundScriptRecord = {
|
|
@@ -11416,7 +12356,7 @@ export class AgentFramework {
|
|
|
11416
12356
|
// stays reachable by provenance after the bytes leave the wire.
|
|
11417
12357
|
const raw = tc.result.isError
|
|
11418
12358
|
? tc.result.error ?? 'Unknown error'
|
|
11419
|
-
:
|
|
12359
|
+
: nonErrorResultText(tc.result, undefined, {
|
|
11420
12360
|
imageRef: (blockIndex, image) => ledger.retain({
|
|
11421
12361
|
toolCallId: tc.id,
|
|
11422
12362
|
toolName: tc.name,
|
|
@@ -11439,6 +12379,133 @@ export class AgentFramework {
|
|
|
11439
12379
|
return { blocks, spilled };
|
|
11440
12380
|
}
|
|
11441
12381
|
|
|
12382
|
+
/** Where a tool result labelled `label` is written: `<first writable
|
|
12383
|
+
* mount>/tool-results/<label>.txt`, or null when there is no writable
|
|
12384
|
+
* workspace. */
|
|
12385
|
+
private toolResultFilePlace(label: string): { workspace: WorkspaceModule; path: string; safeLabel: string } | null {
|
|
12386
|
+
const workspace = this.getWorkspaceModule();
|
|
12387
|
+
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
12388
|
+
if (!workspace || !mountName) return null;
|
|
12389
|
+
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
12390
|
+
return { workspace, path: `${mountName}/tool-results/${safeLabel}.txt`, safeLabel };
|
|
12391
|
+
}
|
|
12392
|
+
|
|
12393
|
+
/**
|
|
12394
|
+
* A withheld original uses the remaining time in its batch's deadline.
|
|
12395
|
+
* A write still under way at that deadline is reported as unfinished and
|
|
12396
|
+
* goes on; the guard stops waiting for it, though stop() still waits for
|
|
12397
|
+
* the workspace's turn it holds (bounded only by a teardown timeout). From
|
|
12398
|
+
* then until every
|
|
12399
|
+
* overdue write settles, new withheld spills are workspace_unanswered.
|
|
12400
|
+
* Writes from other batches can start before the first deadline fires.
|
|
12401
|
+
* An exhausted batch starts no more writes even if none is overdue.
|
|
12402
|
+
* The inline cap's spills use writeToolResultFile without this deadline.
|
|
12403
|
+
*/
|
|
12404
|
+
private async spillWithheld(label: string, text: string, branch: string,
|
|
12405
|
+
deadline = Date.now() + this.withheldSpillDeadlineMs): Promise<WithheldSpill> {
|
|
12406
|
+
// A batch with no finite deadline (a host whose deadline() threw) gets
|
|
12407
|
+
// this write's own, rather than a timer Node would cut to 1 ms.
|
|
12408
|
+
if (!Number.isFinite(deadline)) deadline = Date.now() + this.withheldSpillDeadlineMs;
|
|
12409
|
+
if (this.guardSpillsClosedFor === this.lifecycleGeneration) return null;
|
|
12410
|
+
const place = this.toolResultFilePlace(label);
|
|
12411
|
+
if (!place) return null;
|
|
12412
|
+
if (this.stalledSpills.size > 0) {
|
|
12413
|
+
// Said once per stall, with the count when it clears, so a stalled
|
|
12414
|
+
// workspace doesn't write a line for every result it can't take.
|
|
12415
|
+
if (this.stallSkips++ === 0) {
|
|
12416
|
+
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})`);
|
|
12417
|
+
}
|
|
12418
|
+
return { skipped: 'workspace_unanswered' };
|
|
12419
|
+
}
|
|
12420
|
+
const remaining = deadline - Date.now();
|
|
12421
|
+
// The guard says once per batch that it used its deadline.
|
|
12422
|
+
if (remaining <= 0) return { skipped: 'batch_deadline' };
|
|
12423
|
+
const write = this.writeToolResultFile(label, text, branch);
|
|
12424
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
12425
|
+
const late = new Promise<'late'>((resolve) => {
|
|
12426
|
+
timer = setTimeout(() => {
|
|
12427
|
+
// Marked as the deadline passes, before anything else runs, so no
|
|
12428
|
+
// spill that starts after it finds the workspace answering.
|
|
12429
|
+
const stalled: Promise<void> = write.then(() => {}, () => {}).finally(() => {
|
|
12430
|
+
this.stalledSpills.delete(stalled);
|
|
12431
|
+
if (this.stalledSpills.size === 0 && this.stallSkips > 0) {
|
|
12432
|
+
console.error(`[spill] the workspace answered again; ${this.stallSkips} withheld original(s) were skipped as workspace_unanswered meanwhile`);
|
|
12433
|
+
this.stallSkips = 0;
|
|
12434
|
+
}
|
|
12435
|
+
});
|
|
12436
|
+
this.stalledSpills.add(stalled);
|
|
12437
|
+
console.error(`[spill] ${place.path} still being written at the withheld batch deadline`);
|
|
12438
|
+
resolve('late');
|
|
12439
|
+
}, remaining);
|
|
12440
|
+
timer.unref?.();
|
|
12441
|
+
});
|
|
12442
|
+
try {
|
|
12443
|
+
const written = await Promise.race([write, late]);
|
|
12444
|
+
if (written !== 'late') {
|
|
12445
|
+
if (!written) return null;
|
|
12446
|
+
if (written.branchLeft) return { skipped: 'branch_changed' };
|
|
12447
|
+
return { path: written.path, ...(written.error !== undefined ? { error: written.error } : {}) };
|
|
12448
|
+
}
|
|
12449
|
+
return { path: place.path, unfinished: true };
|
|
12450
|
+
} finally {
|
|
12451
|
+
clearTimeout(timer);
|
|
12452
|
+
}
|
|
12453
|
+
}
|
|
12454
|
+
|
|
12455
|
+
/**
|
|
12456
|
+
* Write one tool result's text to its tool-results file (see
|
|
12457
|
+
* toolResultFilePlace): the one place both the inline cap's spill and the
|
|
12458
|
+
* guard's withheld originals go. null when there is no writable workspace.
|
|
12459
|
+
* A failed write is reported, traced and logged here, never thrown. With a
|
|
12460
|
+
* branch, the file lands on that branch or isn't written (see writeBinary).
|
|
12461
|
+
*/
|
|
12462
|
+
private async writeToolResultFile(
|
|
12463
|
+
label: string,
|
|
12464
|
+
content: string,
|
|
12465
|
+
branch?: string,
|
|
12466
|
+
): Promise<{ path: string; error?: string; branchLeft?: true } | null> {
|
|
12467
|
+
const place = this.toolResultFilePlace(label);
|
|
12468
|
+
if (!place) return null;
|
|
12469
|
+
const { workspace, path, safeLabel } = place;
|
|
12470
|
+
let failure: string;
|
|
12471
|
+
try {
|
|
12472
|
+
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain', { branch });
|
|
12473
|
+
if (result.success) return { path };
|
|
12474
|
+
// The file is in the workspace's Chronicle tree, where its tools read
|
|
12475
|
+
// it, and the rest of the write failed (its copy on disk, or something
|
|
12476
|
+
// after the entry was set): it is written for every reader the stub or
|
|
12477
|
+
// the truncation note sends there. The workspace emits
|
|
12478
|
+
// workspace:materialize-failed only for a refused push, so the failure
|
|
12479
|
+
// is traced here too.
|
|
12480
|
+
if ((result.data as { inChronicle?: boolean } | undefined)?.inChronicle === true) {
|
|
12481
|
+
const error = (result.error ?? 'the rest of the write failed').slice(0, 200);
|
|
12482
|
+
this.emitTrace({ type: 'tool:spill_failed', label: safeLabel, path, contentLength: content.length, error });
|
|
12483
|
+
console.error(`[spill] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${error}`);
|
|
12484
|
+
return { path };
|
|
12485
|
+
}
|
|
12486
|
+
// Declined, not failed: the store left the branch the file is for, so
|
|
12487
|
+
// it isn't written anywhere, as the caller asked.
|
|
12488
|
+
if ((result.data as { branchLeft?: boolean } | undefined)?.branchLeft === true) {
|
|
12489
|
+
return { path, error: result.error ?? 'the workspace had left the branch this file is for', branchLeft: true };
|
|
12490
|
+
}
|
|
12491
|
+
failure = result.error ?? 'write refused';
|
|
12492
|
+
} catch (err) {
|
|
12493
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
12494
|
+
}
|
|
12495
|
+
failure = failure.slice(0, 200);
|
|
12496
|
+
this.emitTrace({
|
|
12497
|
+
type: 'tool:spill_failed',
|
|
12498
|
+
label: safeLabel,
|
|
12499
|
+
path,
|
|
12500
|
+
contentLength: content.length,
|
|
12501
|
+
error: failure,
|
|
12502
|
+
});
|
|
12503
|
+
console.error(
|
|
12504
|
+
`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
|
|
12505
|
+
);
|
|
12506
|
+
return { path, error: failure };
|
|
12507
|
+
}
|
|
12508
|
+
|
|
11442
12509
|
private async spillOrTruncate(
|
|
11443
12510
|
content: string,
|
|
11444
12511
|
cap: number | undefined,
|
|
@@ -11453,52 +12520,32 @@ export class AgentFramework {
|
|
|
11453
12520
|
const { head, tail } = splitPreservingImageSlots(content, cap);
|
|
11454
12521
|
const kept = formatPreservedImageSlots(tail);
|
|
11455
12522
|
|
|
11456
|
-
const
|
|
11457
|
-
|
|
11458
|
-
|
|
11459
|
-
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
|
|
11463
|
-
|
|
11464
|
-
|
|
11465
|
-
|
|
11466
|
-
|
|
11467
|
-
|
|
11468
|
-
|
|
11469
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
11472
|
-
|
|
11473
|
-
|
|
11474
|
-
|
|
11475
|
-
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
11476
|
-
+ kept,
|
|
11477
|
-
filePath: path,
|
|
11478
|
-
};
|
|
11479
|
-
}
|
|
11480
|
-
failure = result.error ?? 'write refused';
|
|
11481
|
-
} catch (err) {
|
|
11482
|
-
failure = err instanceof Error ? err.message : String(err);
|
|
11483
|
-
}
|
|
12523
|
+
const written = await this.writeToolResultFile(label, content);
|
|
12524
|
+
if (written && written.error === undefined) {
|
|
12525
|
+
const path = written.path;
|
|
12526
|
+
// JSON serialization can expand each read-back code unit to six
|
|
12527
|
+
// chars (\\u0000). Leave room for the page's metadata and a possible
|
|
12528
|
+
// extra code unit at a surrogate-pair boundary.
|
|
12529
|
+
const pageOverhead = JSON.stringify(path).length + 256;
|
|
12530
|
+
const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
|
|
12531
|
+
return {
|
|
12532
|
+
text: head
|
|
12533
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
|
|
12534
|
+
+ `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
|
|
12535
|
+
+ 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
|
|
12536
|
+
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
12537
|
+
+ kept,
|
|
12538
|
+
filePath: path,
|
|
12539
|
+
};
|
|
12540
|
+
}
|
|
12541
|
+
if (written) {
|
|
11484
12542
|
// A workspace was there and the write FAILED — say that, loudly and
|
|
11485
12543
|
// distinctly. Telling the agent "no writable workspace" would teach
|
|
11486
12544
|
// them their residence lacks a capability it actually has.
|
|
11487
|
-
failure = failure.slice(0, 200);
|
|
11488
|
-
this.emitTrace({
|
|
11489
|
-
type: 'tool:spill_failed',
|
|
11490
|
-
label: safeLabel,
|
|
11491
|
-
path,
|
|
11492
|
-
contentLength: content.length,
|
|
11493
|
-
error: failure,
|
|
11494
|
-
});
|
|
11495
|
-
console.error(
|
|
11496
|
-
`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
|
|
11497
|
-
);
|
|
11498
12545
|
return {
|
|
11499
12546
|
text: head
|
|
11500
|
-
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
11501
|
-
+ ` (${
|
|
12547
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
|
|
12548
|
+
+ ` (${written.error}); content over the cap was not retained]`
|
|
11502
12549
|
+ kept,
|
|
11503
12550
|
filePath: null,
|
|
11504
12551
|
};
|
|
@@ -11521,7 +12568,13 @@ export class AgentFramework {
|
|
|
11521
12568
|
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
11522
12569
|
idleReclaimMs: cfg?.idleReclaimMs,
|
|
11523
12570
|
label: agentName,
|
|
11524
|
-
|
|
12571
|
+
// The script that made the call is the one running when it arrives.
|
|
12572
|
+
onToolCall: (toolName, args) =>
|
|
12573
|
+
this.handleScriptToolCall(
|
|
12574
|
+
agentName, toolName, args,
|
|
12575
|
+
this.foregroundScriptOrigins.get(agentName),
|
|
12576
|
+
this.scriptDeferredEndTurn.get(agentName),
|
|
12577
|
+
),
|
|
11525
12578
|
});
|
|
11526
12579
|
this.codeExecutionRunners.set(agentName, runner);
|
|
11527
12580
|
}
|
|
@@ -11541,21 +12594,24 @@ export class AgentFramework {
|
|
|
11541
12594
|
agentName: string,
|
|
11542
12595
|
toolName: string,
|
|
11543
12596
|
args: Record<string, unknown>,
|
|
12597
|
+
origin?: ScriptCallOrigin,
|
|
12598
|
+
/** The foreground script that made the call; none for a background script. */
|
|
12599
|
+
script?: { endTurn: boolean },
|
|
11544
12600
|
): Promise<string> {
|
|
11545
12601
|
if (toolName === CODE_EXECUTION_TOOL_NAME) {
|
|
11546
12602
|
return 'Error: code_execution cannot be called from within a script';
|
|
11547
12603
|
}
|
|
11548
|
-
const result = await this.dispatchScriptToolCall(agentName, toolName, args);
|
|
11549
|
-
if (result.endTurn) {
|
|
12604
|
+
const result = await this.dispatchScriptToolCall(agentName, toolName, args, origin);
|
|
12605
|
+
if (result.endTurn && script) {
|
|
11550
12606
|
// Deferred: applied to the final code_execution result (see
|
|
11551
12607
|
// scriptDeferredEndTurn) — ending the turn mid-script would cancel the
|
|
11552
12608
|
// stream while the script still runs and wedge the tool round.
|
|
11553
|
-
|
|
12609
|
+
script.endTurn = true;
|
|
11554
12610
|
}
|
|
11555
12611
|
if (result.isError) {
|
|
11556
12612
|
return `Error: ${result.error ?? 'tool call failed'}`;
|
|
11557
12613
|
}
|
|
11558
|
-
if (result.data === undefined) return
|
|
12614
|
+
if (result.data === undefined || result.data === null) return nonErrorResultText(result, 5_000_000);
|
|
11559
12615
|
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
|
|
11560
12616
|
// available signal that the bytes are wanted (scripts process, they do
|
|
11561
12617
|
// not browse) — materialize every referenced payload now, so the stub
|
|
@@ -11582,11 +12638,16 @@ export class AgentFramework {
|
|
|
11582
12638
|
* resolve with its result. The waiter intercept in handleProcessEvent
|
|
11583
12639
|
* (keyed by the pytc- call ID) routes the tool-result event here instead
|
|
11584
12640
|
* of into the agent's pending tool round.
|
|
12641
|
+
*
|
|
12642
|
+
* RFC-007: with an `origin`, observers see the call as they see a model
|
|
12643
|
+
* call (register, dispatch with its refusal sites, open; the terminal at
|
|
12644
|
+
* the waiter intercept), under the inner tool's own name and class.
|
|
11585
12645
|
*/
|
|
11586
12646
|
private dispatchScriptToolCall(
|
|
11587
12647
|
agentName: string,
|
|
11588
12648
|
toolName: string,
|
|
11589
12649
|
args: Record<string, unknown>,
|
|
12650
|
+
origin?: ScriptCallOrigin,
|
|
11590
12651
|
): Promise<ToolResult> {
|
|
11591
12652
|
return new Promise<ToolResult>((resolve) => {
|
|
11592
12653
|
const callId = `pytc-${randomUUID()}`;
|
|
@@ -11596,6 +12657,9 @@ export class AgentFramework {
|
|
|
11596
12657
|
const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
|
|
11597
12658
|
const safety = setTimeout(() => {
|
|
11598
12659
|
if (this.scriptToolWaiters.delete(callId)) {
|
|
12660
|
+
// RFC-007: no result reached the host — the call failed.
|
|
12661
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
|
|
12662
|
+
this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
|
|
11599
12663
|
resolve({
|
|
11600
12664
|
success: false,
|
|
11601
12665
|
error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
|
|
@@ -11610,15 +12674,28 @@ export class AgentFramework {
|
|
|
11610
12674
|
resolve(result);
|
|
11611
12675
|
});
|
|
11612
12676
|
|
|
12677
|
+
const call = { id: callId, name: toolName, input: args };
|
|
12678
|
+
if (origin) this.toolLifecycleEmitter?.registerScriptCall(agentName, origin, call);
|
|
11613
12679
|
try {
|
|
11614
|
-
this.dispatchToolCall(agentName,
|
|
12680
|
+
this.dispatchToolCall(agentName, call);
|
|
11615
12681
|
} catch (error) {
|
|
12682
|
+
// Dispatch threw. Not a refusal (refusal sites call refuse() and
|
|
12683
|
+
// answer): the tool may have done work first (sleep sets the gate,
|
|
12684
|
+
// then formats its reply), so the call started and the host got no
|
|
12685
|
+
// result — `failed`, as for a dispatch that rejects.
|
|
12686
|
+
if (origin) {
|
|
12687
|
+
this.toolLifecycleEmitter?.open(agentName, callId);
|
|
12688
|
+
this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
|
|
12689
|
+
this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
|
|
12690
|
+
}
|
|
11616
12691
|
if (this.scriptToolWaiters.delete(callId)) {
|
|
11617
12692
|
clearTimeout(safety);
|
|
11618
12693
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
11619
12694
|
resolve({ success: false, error: err.message, isError: true });
|
|
11620
12695
|
}
|
|
12696
|
+
return;
|
|
11621
12697
|
}
|
|
12698
|
+
if (origin) this.toolLifecycleEmitter?.open(agentName, callId);
|
|
11622
12699
|
});
|
|
11623
12700
|
}
|
|
11624
12701
|
|
|
@@ -11967,6 +13044,12 @@ export class AgentFramework {
|
|
|
11967
13044
|
private dispatchToolCall(agentName: string, call: ToolCall): void {
|
|
11968
13045
|
// Enrich call with caller identity so modules can resolve the calling agent
|
|
11969
13046
|
const enrichedCall: ToolCall = { ...call, callerAgentName: agentName };
|
|
13047
|
+
if (isPresentationTool(call.name)) {
|
|
13048
|
+
const result = this.editToolPresentation(agentName, enrichedCall);
|
|
13049
|
+
this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result});
|
|
13050
|
+
return;
|
|
13051
|
+
}
|
|
13052
|
+
|
|
11970
13053
|
|
|
11971
13054
|
// Route MCPL tool calls to the appropriate server via prefix map
|
|
11972
13055
|
const mcplMatch = this.resolveMcplTool(enrichedCall.name);
|
|
@@ -12025,10 +13108,16 @@ export class AgentFramework {
|
|
|
12025
13108
|
) {
|
|
12026
13109
|
void this.tuneOutCoordinator
|
|
12027
13110
|
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>)
|
|
13111
|
+
// A rejection is answered with a failed result, as a framework tool's
|
|
13112
|
+
// dispatch failure is (dispatchUtilsToolCall), never left unhandled.
|
|
13113
|
+
.catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true }))
|
|
12028
13114
|
.then((result) => {
|
|
12029
|
-
|
|
13115
|
+
// Through pushEvent, like every other tool's result, so one that
|
|
13116
|
+
// arrives after stop() is dropped rather than rejected unhandled.
|
|
13117
|
+
this.pushEvent({
|
|
12030
13118
|
type: 'tool-result',
|
|
12031
13119
|
callId: enrichedCall.id,
|
|
13120
|
+
toolName: enrichedCall.name,
|
|
12032
13121
|
agentName,
|
|
12033
13122
|
moduleName: 'tune-out',
|
|
12034
13123
|
result,
|
|
@@ -12099,6 +13188,7 @@ export class AgentFramework {
|
|
|
12099
13188
|
this.pushEvent({
|
|
12100
13189
|
type: 'tool-result',
|
|
12101
13190
|
callId: enrichedCall.id,
|
|
13191
|
+
toolName: enrichedCall.name,
|
|
12102
13192
|
agentName,
|
|
12103
13193
|
moduleName: 'framework',
|
|
12104
13194
|
result: { success: true, data: PROSE_ROUTING_HELP },
|
|
@@ -12134,7 +13224,7 @@ export class AgentFramework {
|
|
|
12134
13224
|
const startTime = Date.now();
|
|
12135
13225
|
|
|
12136
13226
|
this.moduleRegistry
|
|
12137
|
-
.
|
|
13227
|
+
.storableToolCall({ ...call, callerAgentName: agentName })
|
|
12138
13228
|
.then((result) => {
|
|
12139
13229
|
const durationMs = Date.now() - startTime;
|
|
12140
13230
|
this.emitTrace({
|
|
@@ -12148,6 +13238,7 @@ export class AgentFramework {
|
|
|
12148
13238
|
this.pushEvent({
|
|
12149
13239
|
type: 'tool-result',
|
|
12150
13240
|
callId: call.id,
|
|
13241
|
+
toolName: call.name,
|
|
12151
13242
|
agentName,
|
|
12152
13243
|
moduleName,
|
|
12153
13244
|
result,
|
|
@@ -12169,6 +13260,7 @@ export class AgentFramework {
|
|
|
12169
13260
|
this.pushEvent({
|
|
12170
13261
|
type: 'tool-result',
|
|
12171
13262
|
callId: call.id,
|
|
13263
|
+
toolName: call.name,
|
|
12172
13264
|
agentName,
|
|
12173
13265
|
moduleName,
|
|
12174
13266
|
result: {
|
|
@@ -12821,7 +13913,9 @@ export class AgentFramework {
|
|
|
12821
13913
|
inferenceRouting?: import('./mcpl/types.js').InferenceRoutingPolicy,
|
|
12822
13914
|
): Promise<void> {
|
|
12823
13915
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
12824
|
-
this.featureSetManager = new FeatureSetManager(
|
|
13916
|
+
this.featureSetManager = new FeatureSetManager(
|
|
13917
|
+
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
13918
|
+
);
|
|
12825
13919
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
12826
13920
|
this.toolLifecycleEmitter = new ToolLifecycleEmitter({
|
|
12827
13921
|
observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
|
|
@@ -13457,6 +14551,22 @@ export class AgentFramework {
|
|
|
13457
14551
|
private manifestRefreshState: Map<string, { inFlight: boolean; queued: boolean; lastCompletedAt: number }> = new Map();
|
|
13458
14552
|
private static readonly MANIFEST_FETCH_FLOOR_MS = 5_000;
|
|
13459
14553
|
|
|
14554
|
+
/**
|
|
14555
|
+
* The `mcpl:policy-refused` fields from a refusal receipt, as the host
|
|
14556
|
+
* acts on them: the receipt is server-supplied, so `fallback` is the
|
|
14557
|
+
* branch actually taken (anything but `close` is treated as `mcp-only`)
|
|
14558
|
+
* and a non-string `reason` is dropped.
|
|
14559
|
+
*/
|
|
14560
|
+
private static policyRefusalFields(receipt: { fallback?: unknown; reason?: unknown }): {
|
|
14561
|
+
fallback: 'close' | 'mcp-only';
|
|
14562
|
+
reason: string | null;
|
|
14563
|
+
} {
|
|
14564
|
+
return {
|
|
14565
|
+
fallback: receipt.fallback === 'close' ? 'close' : 'mcp-only',
|
|
14566
|
+
reason: typeof receipt.reason === 'string' ? receipt.reason : null,
|
|
14567
|
+
};
|
|
14568
|
+
}
|
|
14569
|
+
|
|
13460
14570
|
/**
|
|
13461
14571
|
* §17.5 host processing: fetch the complete manifest, validate exactly as
|
|
13462
14572
|
* at initialize, diff, apply §6.7 consequences (removals eagerly even
|
|
@@ -13572,6 +14682,12 @@ export class AgentFramework {
|
|
|
13572
14682
|
if (receipt && receipt.accepted === false) {
|
|
13573
14683
|
const fallback = receipt.fallback ?? 'mcp-only';
|
|
13574
14684
|
console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
|
|
14685
|
+
this.emitTrace({
|
|
14686
|
+
type: 'mcpl:policy-refused',
|
|
14687
|
+
serverId: connection.id,
|
|
14688
|
+
phase: 'manifest-change',
|
|
14689
|
+
...AgentFramework.policyRefusalFields(receipt),
|
|
14690
|
+
});
|
|
13575
14691
|
if (fallback === 'close') {
|
|
13576
14692
|
await connection.close();
|
|
13577
14693
|
return;
|
|
@@ -13684,6 +14800,12 @@ export class AgentFramework {
|
|
|
13684
14800
|
console.error(
|
|
13685
14801
|
`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`,
|
|
13686
14802
|
);
|
|
14803
|
+
this.emitTrace({
|
|
14804
|
+
type: 'mcpl:policy-refused',
|
|
14805
|
+
serverId: config.id,
|
|
14806
|
+
phase: 'initial',
|
|
14807
|
+
...AgentFramework.policyRefusalFields(receipt),
|
|
14808
|
+
});
|
|
13687
14809
|
if (fallback === 'close') {
|
|
13688
14810
|
await connection.close();
|
|
13689
14811
|
return;
|
|
@@ -13949,9 +15071,20 @@ export class AgentFramework {
|
|
|
13949
15071
|
params: PushEventParams,
|
|
13950
15072
|
responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
13951
15073
|
) => {
|
|
13952
|
-
const
|
|
13953
|
-
|
|
13954
|
-
|
|
15074
|
+
const answer = answerOnce(responder, 'push/event', connection.id);
|
|
15075
|
+
try {
|
|
15076
|
+
const barrier = this.discordAwarenessBarrier;
|
|
15077
|
+
if (barrier) await barrier.promise;
|
|
15078
|
+
// Checked here, after the barrier and before anything records the
|
|
15079
|
+
// push as accepted: a stopped host refuses it.
|
|
15080
|
+
if (this.queue?.isClosed) {
|
|
15081
|
+
this.refuseServerItem('push/event', connection.id, answer, { kind: 'refused' });
|
|
15082
|
+
return;
|
|
15083
|
+
}
|
|
15084
|
+
await this.pushHandler?.handlePushEvent(connection.id, params, answer.responder as never);
|
|
15085
|
+
} catch (error) {
|
|
15086
|
+
this.refuseServerItem('push/event', connection.id, answer, { kind: 'failed', error });
|
|
15087
|
+
}
|
|
13955
15088
|
});
|
|
13956
15089
|
|
|
13957
15090
|
// Handle server-initiated inference requests (Step 6)
|
|
@@ -13961,26 +15094,50 @@ export class AgentFramework {
|
|
|
13961
15094
|
) => {
|
|
13962
15095
|
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
|
|
13963
15096
|
if (this.pushCoalescer?.isRendering(connection.id)) {
|
|
13964
|
-
|
|
15097
|
+
try {
|
|
15098
|
+
responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
|
|
15099
|
+
} catch {
|
|
15100
|
+
// The connection is going; there is no one left to tell.
|
|
15101
|
+
}
|
|
13965
15102
|
return;
|
|
13966
15103
|
}
|
|
13967
15104
|
if (this.inferenceRouter && responder) {
|
|
13968
|
-
const
|
|
13969
|
-
|
|
13970
|
-
|
|
13971
|
-
|
|
13972
|
-
|
|
13973
|
-
|
|
13974
|
-
|
|
15105
|
+
const answer = answerOnce(responder, 'inference/request', connection.id);
|
|
15106
|
+
try {
|
|
15107
|
+
const barrier = this.discordAwarenessBarrier;
|
|
15108
|
+
if (barrier) await barrier.promise;
|
|
15109
|
+
// A stopping host starts no inference for a server: refused, like a
|
|
15110
|
+
// push, before the router runs.
|
|
15111
|
+
if (this.queue?.isClosed) {
|
|
15112
|
+
this.refuseServerItem('inference/request', connection.id, answer, { kind: 'refused' });
|
|
15113
|
+
return;
|
|
15114
|
+
}
|
|
15115
|
+
await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
|
|
15116
|
+
respond: answer.responder!.respond,
|
|
15117
|
+
respondError: answer.responder!.respondError,
|
|
15118
|
+
requestId: responder.id,
|
|
15119
|
+
});
|
|
15120
|
+
} catch (error) {
|
|
15121
|
+
this.refuseServerItem('inference/request', connection.id, answer, { kind: 'failed', error });
|
|
15122
|
+
}
|
|
13975
15123
|
}
|
|
13976
15124
|
});
|
|
13977
15125
|
|
|
13978
15126
|
// Handle channel registration (Step 7)
|
|
13979
15127
|
connection.on('channels-register', async (
|
|
13980
15128
|
params: ChannelsRegisterParams,
|
|
13981
|
-
responder?: { respond: (result: unknown) => void },
|
|
15129
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void },
|
|
13982
15130
|
) => {
|
|
13983
|
-
|
|
15131
|
+
const answer = answerOnce(responder, 'channels/register', connection.id);
|
|
15132
|
+
try {
|
|
15133
|
+
if (this.queue?.isClosed) {
|
|
15134
|
+
this.refuseServerItem('channels/register', connection.id, answer, { kind: 'refused' });
|
|
15135
|
+
return;
|
|
15136
|
+
}
|
|
15137
|
+
await this.channelRegistry?.handleRegister(connection.id, params, answer.responder as never);
|
|
15138
|
+
} catch (error) {
|
|
15139
|
+
this.refuseServerItem('channels/register', connection.id, answer, { kind: 'failed', error });
|
|
15140
|
+
}
|
|
13984
15141
|
});
|
|
13985
15142
|
|
|
13986
15143
|
// Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
|
|
@@ -13989,9 +15146,18 @@ export class AgentFramework {
|
|
|
13989
15146
|
// filtering plus a diagnostic trace.
|
|
13990
15147
|
connection.on('channels-changed', async (
|
|
13991
15148
|
params: ChannelsChangedParams,
|
|
13992
|
-
responder?: { respond: (result: unknown) => void },
|
|
15149
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void },
|
|
13993
15150
|
) => {
|
|
13994
|
-
|
|
15151
|
+
const answer = answerOnce(responder, 'channels/changed', connection.id);
|
|
15152
|
+
try {
|
|
15153
|
+
if (this.queue?.isClosed) {
|
|
15154
|
+
this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'refused' });
|
|
15155
|
+
return;
|
|
15156
|
+
}
|
|
15157
|
+
await this.channelRegistry?.handleChanged(connection.id, params, answer.responder as never);
|
|
15158
|
+
} catch (error) {
|
|
15159
|
+
this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'failed', error });
|
|
15160
|
+
}
|
|
13995
15161
|
});
|
|
13996
15162
|
|
|
13997
15163
|
// §6.6/§12: requests that previously had no handler and hung forever
|
|
@@ -14050,11 +15216,20 @@ export class AgentFramework {
|
|
|
14050
15216
|
// Handle incoming channel messages (Step 7)
|
|
14051
15217
|
connection.on('channels-incoming', async (
|
|
14052
15218
|
params: ChannelsIncomingParams,
|
|
14053
|
-
responder?: { respond: (result: unknown) => void },
|
|
15219
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void },
|
|
14054
15220
|
) => {
|
|
14055
|
-
const
|
|
14056
|
-
|
|
14057
|
-
|
|
15221
|
+
const answer = answerOnce(responder, 'channels/incoming', connection.id);
|
|
15222
|
+
try {
|
|
15223
|
+
const barrier = this.discordAwarenessBarrier;
|
|
15224
|
+
if (barrier) await barrier.promise;
|
|
15225
|
+
if (this.queue?.isClosed) {
|
|
15226
|
+
this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'refused' });
|
|
15227
|
+
return;
|
|
15228
|
+
}
|
|
15229
|
+
await this.channelRegistry?.handleIncoming(connection.id, params, answer.responder as never);
|
|
15230
|
+
} catch (error) {
|
|
15231
|
+
this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'failed', error });
|
|
15232
|
+
}
|
|
14058
15233
|
});
|
|
14059
15234
|
|
|
14060
15235
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
@@ -14063,14 +15238,20 @@ export class AgentFramework {
|
|
|
14063
15238
|
responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
14064
15239
|
) => {
|
|
14065
15240
|
if (!responder) return;
|
|
15241
|
+
const answer = answerOnce(responder, 'host/command', connection.id);
|
|
14066
15242
|
try {
|
|
14067
15243
|
const barrier = this.discordAwarenessBarrier;
|
|
14068
15244
|
if (barrier) await barrier.promise;
|
|
15245
|
+
// A stopping host runs no host command: refused like the requests
|
|
15246
|
+
// above, before its handler runs.
|
|
15247
|
+
if (this.queue?.isClosed) {
|
|
15248
|
+
this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
|
|
15249
|
+
return;
|
|
15250
|
+
}
|
|
14069
15251
|
const result = await this.handleHostCommand(connection.id, params ?? {});
|
|
14070
|
-
responder
|
|
15252
|
+
answer.responder!.respond(result);
|
|
14071
15253
|
} catch (error) {
|
|
14072
|
-
|
|
14073
|
-
responder.respondError(-32603, err.message);
|
|
15254
|
+
this.refuseServerItem('host/command', connection.id, answer, { kind: 'failed', error });
|
|
14074
15255
|
}
|
|
14075
15256
|
});
|
|
14076
15257
|
|
|
@@ -14243,12 +15424,14 @@ export class AgentFramework {
|
|
|
14243
15424
|
const toolFeatureSets = new Map<string, string>();
|
|
14244
15425
|
const toolClasses = new Map<string, ToolClass[]>();
|
|
14245
15426
|
const toolServers = new Map<string, string>();
|
|
15427
|
+
const listedServers = new Set<string>();
|
|
14246
15428
|
|
|
14247
15429
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
14248
15430
|
const config = this.mcplServerConfigs.get(server.id);
|
|
14249
15431
|
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
14250
15432
|
try {
|
|
14251
15433
|
const result = await server.sendToolsList();
|
|
15434
|
+
listedServers.add(server.id);
|
|
14252
15435
|
for (const tool of result.tools) {
|
|
14253
15436
|
if (!isToolAllowed(tool.name, config)) continue;
|
|
14254
15437
|
const namespacedName = `${prefix}--${tool.name}`;
|
|
@@ -14278,7 +15461,12 @@ export class AgentFramework {
|
|
|
14278
15461
|
}
|
|
14279
15462
|
}
|
|
14280
15463
|
} catch {
|
|
14281
|
-
// Server may not support tools/list — skip silently
|
|
15464
|
+
// Server may not support tools/list — skip silently. A live server
|
|
15465
|
+
// whose handshake advertised no MCP `tools` has, in effect, listed
|
|
15466
|
+
// nothing; count it as listed so tool-pattern checks do not wait on
|
|
15467
|
+
// it forever. (A server that advertised tools but failed to list
|
|
15468
|
+
// them stays unknown.)
|
|
15469
|
+
if (server.isConnected && !server.mcpToolsAdvertised) listedServers.add(server.id);
|
|
14282
15470
|
}
|
|
14283
15471
|
}
|
|
14284
15472
|
|
|
@@ -14286,6 +15474,91 @@ export class AgentFramework {
|
|
|
14286
15474
|
this.mcplToolFeatureSets = toolFeatureSets;
|
|
14287
15475
|
this.mcplToolClasses = toolClasses;
|
|
14288
15476
|
this.mcplToolServers = toolServers;
|
|
15477
|
+
this.mcplListedServers = listedServers;
|
|
15478
|
+
if (this.toolPatternChecksArmed) this.checkToolPatterns();
|
|
15479
|
+
}
|
|
15480
|
+
|
|
15481
|
+
/**
|
|
15482
|
+
* Report operator tool-name patterns that match no model-facing tool:
|
|
15483
|
+
* `toolClassOverrides` keys and each server's `toolLifecycle.observe.tools`
|
|
15484
|
+
* / `toolLifecycle.inputs.tools`. These match `<toolPrefix>--<tool>`, and
|
|
15485
|
+
* the default prefix is `mcpl--<serverId>`, so a pattern written against
|
|
15486
|
+
* the bare server id is valid config that silently does nothing.
|
|
15487
|
+
*
|
|
15488
|
+
* Each pattern is reported at most once (console line + a
|
|
15489
|
+
* `mcpl:tool-pattern-unmatched` trace). A pattern that could still name
|
|
15490
|
+
* tools of a server whose listing is not in (not connected yet,
|
|
15491
|
+
* reconnecting, restarting) stays undecided until that listing arrives.
|
|
15492
|
+
* Diagnostic only: never throws into the refresh that called it.
|
|
15493
|
+
*/
|
|
15494
|
+
private checkToolPatterns(): void {
|
|
15495
|
+
try {
|
|
15496
|
+
const entries: Array<{ setting: string; serverId?: string; pattern: string }> = [];
|
|
15497
|
+
for (const [pattern] of this.toolClassOverrides ?? []) {
|
|
15498
|
+
entries.push({ setting: 'toolClassOverrides', pattern });
|
|
15499
|
+
}
|
|
15500
|
+
const configs = [...(this.mcplServerConfigs?.values() ?? [])];
|
|
15501
|
+
for (const config of configs) {
|
|
15502
|
+
const lifecycle = config.toolLifecycle as Record<string, { tools?: unknown } | undefined> | undefined;
|
|
15503
|
+
for (const key of ['observe', 'inputs'] as const) {
|
|
15504
|
+
const tools = lifecycle?.[key]?.tools;
|
|
15505
|
+
if (!Array.isArray(tools)) continue;
|
|
15506
|
+
for (const pattern of tools) {
|
|
15507
|
+
if (typeof pattern === 'string') {
|
|
15508
|
+
entries.push({ setting: `toolLifecycle.${key}.tools`, serverId: config.id, pattern });
|
|
15509
|
+
}
|
|
15510
|
+
}
|
|
15511
|
+
}
|
|
15512
|
+
}
|
|
15513
|
+
const reported = (this.toolPatternsReported ??= new Set());
|
|
15514
|
+
const keyOf = (e: { setting: string; serverId?: string; pattern: string }) =>
|
|
15515
|
+
`${e.serverId ?? ''}\u0000${e.setting}\u0000${e.pattern}`;
|
|
15516
|
+
const pending = entries.filter((e) => !reported.has(keyOf(e)));
|
|
15517
|
+
if (pending.length === 0) return;
|
|
15518
|
+
|
|
15519
|
+
const known = (this.toolPatternKnownPrefixes ??= new Map());
|
|
15520
|
+
for (const c of configs) known.set(c.toolPrefix ?? `mcpl--${c.id}`, c.id);
|
|
15521
|
+
const servers: PatternServer[] = [...known].map(([prefix, id]) => {
|
|
15522
|
+
const current = this.mcplServerConfigs?.get(id);
|
|
15523
|
+
return {
|
|
15524
|
+
id,
|
|
15525
|
+
prefix,
|
|
15526
|
+
listed: !!current
|
|
15527
|
+
&& (current.toolPrefix ?? `mcpl--${id}`) === prefix
|
|
15528
|
+
&& (this.mcplListedServers?.has(id) ?? false),
|
|
15529
|
+
};
|
|
15530
|
+
});
|
|
15531
|
+
const names = this.modelFacingToolNames();
|
|
15532
|
+
|
|
15533
|
+
for (const entry of pending) {
|
|
15534
|
+
const verdict = checkToolPattern(entry.pattern, names, servers);
|
|
15535
|
+
if (verdict.kind !== 'unmatched') continue;
|
|
15536
|
+
reported.add(keyOf(entry));
|
|
15537
|
+
const where = entry.serverId ? `${entry.serverId}: ${entry.setting}` : entry.setting;
|
|
15538
|
+
console.error(describeUnmatchedPattern(where, entry.pattern, verdict));
|
|
15539
|
+
this.emitTrace({
|
|
15540
|
+
type: 'mcpl:tool-pattern-unmatched',
|
|
15541
|
+
setting: entry.setting,
|
|
15542
|
+
...(entry.serverId ? { serverId: entry.serverId } : {}),
|
|
15543
|
+
pattern: entry.pattern,
|
|
15544
|
+
...(verdict.suggestion ? { suggestion: verdict.suggestion } : {}),
|
|
15545
|
+
hint: verdict.hint,
|
|
15546
|
+
});
|
|
15547
|
+
}
|
|
15548
|
+
} catch (error) {
|
|
15549
|
+
console.error('[mcpl] tool pattern check failed:', error instanceof Error ? error.message : error);
|
|
15550
|
+
}
|
|
15551
|
+
}
|
|
15552
|
+
|
|
15553
|
+
/** Every tool name the framework offers to anyone: the shared board plus
|
|
15554
|
+
* each agent's own surface, before presentation visibility (a hidden tool
|
|
15555
|
+
* is still callable, so classes and lifecycle still apply to it). */
|
|
15556
|
+
private modelFacingToolNames(): Set<string> {
|
|
15557
|
+
const names = new Set(this.getAllTools().map((t) => t.name));
|
|
15558
|
+
for (const agent of this.agents.values()) {
|
|
15559
|
+
for (const tool of this.availableToolsForPresentation(agent.name)) names.add(tool.name);
|
|
15560
|
+
}
|
|
15561
|
+
return names;
|
|
14289
15562
|
}
|
|
14290
15563
|
|
|
14291
15564
|
/**
|
|
@@ -14356,6 +15629,7 @@ export class AgentFramework {
|
|
|
14356
15629
|
this.pushEvent({
|
|
14357
15630
|
type: 'tool-result',
|
|
14358
15631
|
callId: call.id,
|
|
15632
|
+
toolName: call.name,
|
|
14359
15633
|
agentName,
|
|
14360
15634
|
moduleName: `mcpl:${serverId}`,
|
|
14361
15635
|
result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
|
|
@@ -14370,6 +15644,7 @@ export class AgentFramework {
|
|
|
14370
15644
|
this.pushEvent({
|
|
14371
15645
|
type: 'tool-result',
|
|
14372
15646
|
callId: call.id,
|
|
15647
|
+
toolName: call.name,
|
|
14373
15648
|
agentName,
|
|
14374
15649
|
moduleName: `mcpl:${serverId}`,
|
|
14375
15650
|
result: {
|
|
@@ -14529,6 +15804,7 @@ export class AgentFramework {
|
|
|
14529
15804
|
this.pushEvent({
|
|
14530
15805
|
type: 'tool-result',
|
|
14531
15806
|
callId: call.id,
|
|
15807
|
+
toolName: call.name,
|
|
14532
15808
|
agentName,
|
|
14533
15809
|
moduleName: `mcpl:${serverId}`,
|
|
14534
15810
|
result: {
|
|
@@ -14548,6 +15824,7 @@ export class AgentFramework {
|
|
|
14548
15824
|
this.pushEvent({
|
|
14549
15825
|
type: 'tool-result',
|
|
14550
15826
|
callId: call.id,
|
|
15827
|
+
toolName: call.name,
|
|
14551
15828
|
agentName,
|
|
14552
15829
|
moduleName: `mcpl:${serverId}`,
|
|
14553
15830
|
result: { success: false, error: err.message, isError: true },
|
|
@@ -14579,6 +15856,7 @@ export class AgentFramework {
|
|
|
14579
15856
|
this.pushEvent({
|
|
14580
15857
|
type: 'tool-result',
|
|
14581
15858
|
callId: call.id,
|
|
15859
|
+
toolName: call.name,
|
|
14582
15860
|
agentName,
|
|
14583
15861
|
moduleName: 'channels',
|
|
14584
15862
|
result: { success: false, error, isError: true },
|
|
@@ -14647,6 +15925,7 @@ export class AgentFramework {
|
|
|
14647
15925
|
this.pushEvent({
|
|
14648
15926
|
type: 'tool-result',
|
|
14649
15927
|
callId: call.id,
|
|
15928
|
+
toolName: call.name,
|
|
14650
15929
|
agentName,
|
|
14651
15930
|
moduleName: 'channels',
|
|
14652
15931
|
result,
|
|
@@ -14659,6 +15938,7 @@ export class AgentFramework {
|
|
|
14659
15938
|
this.pushEvent({
|
|
14660
15939
|
type: 'tool-result',
|
|
14661
15940
|
callId: call.id,
|
|
15941
|
+
toolName: call.name,
|
|
14662
15942
|
agentName,
|
|
14663
15943
|
moduleName: 'channels',
|
|
14664
15944
|
result: { success: false, error: err.message, isError: true },
|
|
@@ -14680,7 +15960,7 @@ export class AgentFramework {
|
|
|
14680
15960
|
module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
|
|
14681
15961
|
...(result.isError ? { error: result.error } : {}),
|
|
14682
15962
|
});
|
|
14683
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
15963
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
|
|
14684
15964
|
};
|
|
14685
15965
|
|
|
14686
15966
|
try {
|
|
@@ -14850,9 +16130,10 @@ export class AgentFramework {
|
|
|
14850
16130
|
}
|
|
14851
16131
|
: { success: false, error: r.error, isError: false };
|
|
14852
16132
|
}
|
|
14853
|
-
this.
|
|
16133
|
+
this.pushEvent({
|
|
14854
16134
|
type: 'tool-result',
|
|
14855
16135
|
callId: call.id,
|
|
16136
|
+
toolName: call.name,
|
|
14856
16137
|
agentName,
|
|
14857
16138
|
moduleName: 'tune-out',
|
|
14858
16139
|
result,
|
|
@@ -14866,7 +16147,7 @@ export class AgentFramework {
|
|
|
14866
16147
|
|
|
14867
16148
|
const finish = (result: ToolResult) => {
|
|
14868
16149
|
this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
|
|
14869
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
16150
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
|
|
14870
16151
|
};
|
|
14871
16152
|
|
|
14872
16153
|
if (call.name === 'wake') {
|
|
@@ -14900,7 +16181,7 @@ export class AgentFramework {
|
|
|
14900
16181
|
} else if (agent?.proseRouting === 'explicit') {
|
|
14901
16182
|
// Explicit mode: announce to the turn's sticky prose target if the
|
|
14902
16183
|
// model has set one; otherwise stay quiet — never guess a channel.
|
|
14903
|
-
const target = this.
|
|
16184
|
+
const target = this.turnRouting.get(agentName)?.pin;
|
|
14904
16185
|
if (target) {
|
|
14905
16186
|
this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
|
|
14906
16187
|
console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
|
|
@@ -14969,7 +16250,7 @@ export class AgentFramework {
|
|
|
14969
16250
|
...(result.isError ? { error: result.error } : {}),
|
|
14970
16251
|
});
|
|
14971
16252
|
this.pushEvent({
|
|
14972
|
-
type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
|
|
16253
|
+
type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
|
|
14973
16254
|
result: { ...result, isError: result.isError ?? false },
|
|
14974
16255
|
});
|
|
14975
16256
|
})();
|
|
@@ -14986,7 +16267,7 @@ export class AgentFramework {
|
|
|
14986
16267
|
durationMs: 0,
|
|
14987
16268
|
...(result.isError ? { error: result.error } : {}),
|
|
14988
16269
|
});
|
|
14989
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
16270
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
|
|
14990
16271
|
};
|
|
14991
16272
|
void (async (): Promise<void> => {
|
|
14992
16273
|
try {
|
|
@@ -15011,7 +16292,11 @@ export class AgentFramework {
|
|
|
15011
16292
|
typeof workspace.getTools === 'function' &&
|
|
15012
16293
|
workspace.getTools().some((tool) => tool.name === 'read_image')
|
|
15013
16294
|
) {
|
|
15014
|
-
|
|
16295
|
+
// The workspace module's own handler, called directly: its result
|
|
16296
|
+
// gets the same check a module's result gets in the registry. The
|
|
16297
|
+
// agent's name is for the check's log line, not the handler.
|
|
16298
|
+
const moduleCall = { ...call, name: 'read_image' };
|
|
16299
|
+
const result = storableToolResult({ ...moduleCall, callerAgentName: agentName }, await workspace.handleToolCall(moduleCall));
|
|
15015
16300
|
if (result.success && Array.isArray(result.data)) {
|
|
15016
16301
|
const image = result.data.find(
|
|
15017
16302
|
(block): block is { type: 'image'; data: string; mimeType: string } =>
|
|
@@ -15056,11 +16341,7 @@ export class AgentFramework {
|
|
|
15056
16341
|
],
|
|
15057
16342
|
});
|
|
15058
16343
|
} catch (error) {
|
|
15059
|
-
finish(
|
|
15060
|
-
success: false,
|
|
15061
|
-
error: error instanceof Error ? error.message : String(error),
|
|
15062
|
-
isError: true,
|
|
15063
|
-
});
|
|
16344
|
+
finish(thrownResult(error));
|
|
15064
16345
|
}
|
|
15065
16346
|
})();
|
|
15066
16347
|
}
|
|
@@ -15125,7 +16406,7 @@ export class AgentFramework {
|
|
|
15125
16406
|
durationMs: 0,
|
|
15126
16407
|
...(result.isError ? { error: result.error } : {}),
|
|
15127
16408
|
});
|
|
15128
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
16409
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
|
|
15129
16410
|
};
|
|
15130
16411
|
type Candidate =
|
|
15131
16412
|
| { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
|
|
@@ -15421,21 +16702,35 @@ export class AgentFramework {
|
|
|
15421
16702
|
for (const image of found) {
|
|
15422
16703
|
const destination = pathFor(image.rangeOffset);
|
|
15423
16704
|
const bytes = Buffer.from(image.data, 'base64');
|
|
15424
|
-
const
|
|
15425
|
-
|
|
15426
|
-
|
|
15427
|
-
|
|
15428
|
-
|
|
15429
|
-
`
|
|
15430
|
-
|
|
15431
|
-
|
|
15432
|
-
|
|
15433
|
-
|
|
15434
|
-
|
|
16705
|
+
const failed = (why: unknown): void => finish({
|
|
16706
|
+
success: false,
|
|
16707
|
+
error:
|
|
16708
|
+
`save_recent_image: failed writing "${destination}": ${why instanceof Error ? why.message : String(why)}` +
|
|
16709
|
+
(savedFiles.length > 0
|
|
16710
|
+
? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
|
|
16711
|
+
: ''),
|
|
16712
|
+
isError: true,
|
|
16713
|
+
});
|
|
16714
|
+
let result: Awaited<ReturnType<typeof workspace.writeBinary>>;
|
|
16715
|
+
try {
|
|
16716
|
+
result = await workspace.writeBinary(destination, bytes, image.mediaType);
|
|
16717
|
+
} catch (error) {
|
|
16718
|
+
// Thrown before its tree entry was set; the images before it
|
|
16719
|
+
// are saved, and the failure says so, as a returned one does.
|
|
16720
|
+
failed(error);
|
|
16721
|
+
return;
|
|
16722
|
+
}
|
|
16723
|
+
const inChronicle = (result.data as { inChronicle?: boolean } | undefined)?.inChronicle === true;
|
|
16724
|
+
if (!result.success && !inChronicle) {
|
|
16725
|
+
failed(result.error);
|
|
15435
16726
|
return;
|
|
15436
16727
|
}
|
|
15437
16728
|
savedFiles.push({
|
|
15438
16729
|
...(result.data as Record<string, unknown>),
|
|
16730
|
+
path: destination,
|
|
16731
|
+
size: bytes.byteLength,
|
|
16732
|
+
mimeType: image.mediaType,
|
|
16733
|
+
...(!result.success ? { diskCopyError: result.error ?? 'disk copy failed' } : {}),
|
|
15439
16734
|
imageIndex: index + image.rangeOffset,
|
|
15440
16735
|
...image.receipt,
|
|
15441
16736
|
});
|
|
@@ -15463,7 +16758,7 @@ export class AgentFramework {
|
|
|
15463
16758
|
* (Module.getUtilities). Shared by the model-facing dispatcher and the
|
|
15464
16759
|
* programmatic path (executeToolCall), so code_execution scripts and
|
|
15465
16760
|
* ephemerals reach utilities too. */
|
|
15466
|
-
private async handleUtilsToolCall(agentName: string, call: ToolCall): Promise<ToolResult> {
|
|
16761
|
+
private async handleUtilsToolCall(agentName: string, call: ToolCall, options: { inProcess?: boolean } = {}): Promise<ToolResult> {
|
|
15467
16762
|
const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown };
|
|
15468
16763
|
const utilities = this.moduleRegistry.getAllUtilities();
|
|
15469
16764
|
const available = (): string =>
|
|
@@ -15498,13 +16793,12 @@ export class AgentFramework {
|
|
|
15498
16793
|
};
|
|
15499
16794
|
}
|
|
15500
16795
|
// Same dispatch as a first-class tool call — the module cannot tell
|
|
15501
|
-
// which surface the call came through.
|
|
15502
|
-
|
|
15503
|
-
|
|
15504
|
-
|
|
15505
|
-
|
|
15506
|
-
|
|
15507
|
-
});
|
|
16796
|
+
// which surface the call came through. An in-process caller gets the
|
|
16797
|
+
// module's own result; the model's path takes its checked form.
|
|
16798
|
+
const utilityCall = { id: call.id, name: def.name, input: args, callerAgentName: agentName };
|
|
16799
|
+
return options.inProcess
|
|
16800
|
+
? this.moduleRegistry.handleToolCall(utilityCall)
|
|
16801
|
+
: this.moduleRegistry.storableToolCall(utilityCall);
|
|
15508
16802
|
}
|
|
15509
16803
|
default:
|
|
15510
16804
|
return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
|
|
@@ -15529,6 +16823,7 @@ export class AgentFramework {
|
|
|
15529
16823
|
this.pushEvent({
|
|
15530
16824
|
type: 'tool-result',
|
|
15531
16825
|
callId: call.id,
|
|
16826
|
+
toolName: call.name,
|
|
15532
16827
|
agentName,
|
|
15533
16828
|
moduleName: 'framework',
|
|
15534
16829
|
result,
|
|
@@ -15702,6 +16997,7 @@ export class AgentFramework {
|
|
|
15702
16997
|
this.pushEvent({
|
|
15703
16998
|
type: 'tool-result',
|
|
15704
16999
|
callId: call.id,
|
|
17000
|
+
toolName: call.name,
|
|
15705
17001
|
agentName,
|
|
15706
17002
|
moduleName: 'agent',
|
|
15707
17003
|
result,
|
|
@@ -15742,7 +17038,7 @@ export class AgentFramework {
|
|
|
15742
17038
|
this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
|
|
15743
17039
|
result = { success: false, error: err.message, isError: true };
|
|
15744
17040
|
}
|
|
15745
|
-
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
17041
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
|
|
15746
17042
|
}
|
|
15747
17043
|
|
|
15748
17044
|
private dispatchGateToolCall(agentName: string, call: ToolCall): void {
|
|
@@ -15756,6 +17052,7 @@ export class AgentFramework {
|
|
|
15756
17052
|
this.pushEvent({
|
|
15757
17053
|
type: 'tool-result',
|
|
15758
17054
|
callId: call.id,
|
|
17055
|
+
toolName: call.name,
|
|
15759
17056
|
agentName,
|
|
15760
17057
|
moduleName: 'gate',
|
|
15761
17058
|
result,
|
|
@@ -15768,6 +17065,7 @@ export class AgentFramework {
|
|
|
15768
17065
|
this.pushEvent({
|
|
15769
17066
|
type: 'tool-result',
|
|
15770
17067
|
callId: call.id,
|
|
17068
|
+
toolName: call.name,
|
|
15771
17069
|
agentName,
|
|
15772
17070
|
moduleName: 'gate',
|
|
15773
17071
|
result: { success: false, error: err.message, isError: true },
|