@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
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* bookkeeping and is unit-tested without a framework.
|
|
19
19
|
*/
|
|
20
20
|
import type { McplContentBlock, PushEventResult } from './types.js';
|
|
21
|
+
import { isVisiblyEmptyContent } from './visible-content.js';
|
|
21
22
|
|
|
22
23
|
export const PUSH_COALESCING_SUPPORT = {
|
|
23
24
|
pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true,
|
|
@@ -36,6 +37,15 @@ export class CoalesceError extends Error {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** Content with nothing model-visible (see visible-content.ts): -32602. */
|
|
41
|
+
export class EmptyContentError extends CoalesceError {
|
|
42
|
+
readonly reason = 'empty-content';
|
|
43
|
+
constructor(field = 'payload.content') {
|
|
44
|
+
super(field, 'content has no visible content: send at least one non-text block or non-whitespace text');
|
|
45
|
+
this.name = 'EmptyContentError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** Where an unread occurrence currently lives. */
|
|
40
50
|
export interface CoalescingPlacement {
|
|
41
51
|
agent: string;
|
|
@@ -100,6 +110,18 @@ interface Rendering<E> {
|
|
|
100
110
|
batch: DeferredBatch<E>;
|
|
101
111
|
cancelled: boolean;
|
|
102
112
|
done: Promise<void>;
|
|
113
|
+
/** Set once the render (or its fallback) was delivered as content. */
|
|
114
|
+
materialized?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What an assembly did, per subject, for the turn being assembled. */
|
|
118
|
+
export interface AssemblyResult {
|
|
119
|
+
/** Subjects whose render this assembly waited on to completion. */
|
|
120
|
+
settled: Set<string>;
|
|
121
|
+
/** Of those, the subjects whose render or fallback was delivered as content.
|
|
122
|
+
* A settled subject that is not here gave the turn nothing to read: empty
|
|
123
|
+
* or blank render (§5.2), cancelled, or revoked at response. */
|
|
124
|
+
materialized: Set<string>;
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
interface SubjectState<E> {
|
|
@@ -207,8 +229,20 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
|
|
|
207
229
|
return out;
|
|
208
230
|
}
|
|
209
231
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Validate wire content before it is stored, rendered or converted.
|
|
234
|
+
*
|
|
235
|
+
* Visibly-empty content is refused unless `allowEmpty`: an admitted
|
|
236
|
+
* occurrence with nothing to show would wake the model with no visible cause.
|
|
237
|
+
* Empty is legitimate only where the RFC gives it a meaning — a retraction
|
|
238
|
+
* with nothing to announce (§6) and a render result that says nothing
|
|
239
|
+
* happened (§5.2) — and for the silent-heartbeat marker.
|
|
240
|
+
*/
|
|
241
|
+
export function validateCoalescedContent(
|
|
242
|
+
content: unknown,
|
|
243
|
+
maxBytes = 1024 * 1024,
|
|
244
|
+
options: { allowEmpty?: boolean } = {},
|
|
245
|
+
): asserts content is McplContentBlock[] {
|
|
212
246
|
if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
|
|
213
247
|
for (const b of content as Array<Record<string, unknown>>) {
|
|
214
248
|
if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
|
|
@@ -219,6 +253,7 @@ export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 102
|
|
|
219
253
|
throw new CoalesceError('payload.content', 'invalid content block');
|
|
220
254
|
}
|
|
221
255
|
if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
|
|
256
|
+
if (!options.allowEmpty && isVisiblyEmptyContent(content)) throw new EmptyContentError();
|
|
222
257
|
}
|
|
223
258
|
|
|
224
259
|
/** Validate the `coalesce` member of either lane (§13). */
|
|
@@ -486,7 +521,9 @@ export class PushCoalescer<E = unknown> {
|
|
|
486
521
|
this.host.cancelWake(subject);
|
|
487
522
|
state.consumedEventId = undefined;
|
|
488
523
|
if (state.history === 'none') return 'retracted';
|
|
489
|
-
|
|
524
|
+
// An empty notice — or one with only blank text, which would reach the
|
|
525
|
+
// model as an empty message — is pure withdrawal: append nothing.
|
|
526
|
+
if (isVisiblyEmptyContent(occurrence.content)) return 'consumed';
|
|
490
527
|
// The notice is an ordinary occurrence: it rides the normal delivery path
|
|
491
528
|
// (tags → gate policy → wake) and is consumed like any message — in the
|
|
492
529
|
// context that read a version, never a new one (§3.2).
|
|
@@ -563,20 +600,29 @@ export class PushCoalescer<E = unknown> {
|
|
|
563
600
|
* Render and materialize every pending batch whose audience includes
|
|
564
601
|
* `agentName`. Called by the host at a turn's assembly boundary, before the
|
|
565
602
|
* compile. Bounded by the host's render timeout; a failed or late render
|
|
566
|
-
* materializes the admitted fallback (§5.3).
|
|
603
|
+
* materializes the admitted fallback (§5.3). Reports which subjects
|
|
604
|
+
* settled and which of them produced content, so the host can tell a turn
|
|
605
|
+
* whose only cause rendered nothing.
|
|
567
606
|
*/
|
|
568
|
-
async assemble(agentName: string): Promise<
|
|
569
|
-
|
|
607
|
+
async assemble(agentName: string): Promise<AssemblyResult> {
|
|
608
|
+
const result: AssemblyResult = { settled: new Set(), materialized: new Set() };
|
|
609
|
+
if (this.suspended) return result;
|
|
570
610
|
const work: Promise<void>[] = [];
|
|
571
611
|
for (const subject of [...this.subjects.keys()]) {
|
|
572
612
|
// The handle is wrapped: a bare promise returned through `locked` would
|
|
573
613
|
// be flattened by `then`, holding the lock until the render settled —
|
|
574
614
|
// and the settlement itself needs the lock.
|
|
575
615
|
const started = await this.locked(() => this.freeze(subject, agentName));
|
|
576
|
-
if (started)
|
|
616
|
+
if (started) {
|
|
617
|
+
work.push(started.done.then(() => {
|
|
618
|
+
result.settled.add(subject);
|
|
619
|
+
if (started.rendering.materialized) result.materialized.add(subject);
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
577
622
|
}
|
|
578
623
|
await Promise.all(work);
|
|
579
624
|
this.persist();
|
|
625
|
+
return result;
|
|
580
626
|
}
|
|
581
627
|
|
|
582
628
|
/**
|
|
@@ -585,13 +631,13 @@ export class PushCoalescer<E = unknown> {
|
|
|
585
631
|
* the RPC. Returns the render's completion (shared by every assembly that
|
|
586
632
|
* waits on this batch, vector 25) or undefined when nothing was started.
|
|
587
633
|
*/
|
|
588
|
-
private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void> } | undefined> {
|
|
634
|
+
private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void>; rendering: Rendering<E> } | undefined> {
|
|
589
635
|
const state = this.subjects.get(subject);
|
|
590
636
|
if (!state || this.suspended) return undefined;
|
|
591
637
|
if (state.rendering) {
|
|
592
638
|
// Another assembly froze this batch; share its outcome (vector 25).
|
|
593
639
|
const rendering = state.rendering;
|
|
594
|
-
return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined;
|
|
640
|
+
return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done, rendering } : undefined;
|
|
595
641
|
}
|
|
596
642
|
const batch = state.batch;
|
|
597
643
|
if (!batch) return undefined;
|
|
@@ -634,7 +680,7 @@ export class PushCoalescer<E = unknown> {
|
|
|
634
680
|
return undefined;
|
|
635
681
|
}
|
|
636
682
|
rendering.done = this.render(subject, state, rendering, agentName);
|
|
637
|
-
return { done: rendering.done };
|
|
683
|
+
return { done: rendering.done, rendering };
|
|
638
684
|
}
|
|
639
685
|
|
|
640
686
|
private async render(subject: string, state: SubjectState<E>, rendering: Rendering<E>, assemblingFor: string): Promise<void> {
|
|
@@ -654,7 +700,8 @@ export class PushCoalescer<E = unknown> {
|
|
|
654
700
|
try {
|
|
655
701
|
const result = await this.host.render(occurrence, params);
|
|
656
702
|
if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
|
|
657
|
-
|
|
703
|
+
// §5.2: an empty result is legitimate ("nothing happened").
|
|
704
|
+
else validateCoalescedContent(result?.content, this.options.maxContentBytes, { allowEmpty: true });
|
|
658
705
|
if (rendering.cancelled) throw new CancelledRender();
|
|
659
706
|
content = result.content;
|
|
660
707
|
timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
|
|
@@ -673,9 +720,11 @@ export class PushCoalescer<E = unknown> {
|
|
|
673
720
|
if (rendering.cancelled || state.rendering !== rendering || this.suspended) return;
|
|
674
721
|
// Rule 4: authority is re-checked at response.
|
|
675
722
|
if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; }
|
|
676
|
-
|
|
677
|
-
|
|
723
|
+
const empty = isVisiblyEmptyContent(content);
|
|
724
|
+
this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty });
|
|
725
|
+
if (empty) return; // §5.2: nothing happened (blank text included)
|
|
678
726
|
const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
|
|
727
|
+
rendering.materialized = true;
|
|
679
728
|
if (placement?.deferredId) {
|
|
680
729
|
// Landed in another agent's deferred queue (its turn is alive): still
|
|
681
730
|
// unread there, so it stays replaceable and withdrawable.
|
package/src/mcpl/push-handler.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type {
|
|
|
18
18
|
import type { FeatureSetManager } from './feature-set-manager.js';
|
|
19
19
|
import { McplFeatureSetError } from './feature-set-manager.js';
|
|
20
20
|
import { expandCoreTags } from './tags.js';
|
|
21
|
-
import { validateCoalescedContent } from './push-coalescer.js';
|
|
21
|
+
import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
|
|
22
|
+
import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './visible-content.js';
|
|
22
23
|
|
|
23
24
|
// ============================================================================
|
|
24
25
|
// McplPushEvent (the ProcessEvent shape pushed to the queue)
|
|
@@ -188,9 +189,9 @@ export class PushHandler {
|
|
|
188
189
|
* Handle a push/event message from an MCPL server.
|
|
189
190
|
*
|
|
190
191
|
* 1. Validate feature set
|
|
191
|
-
* 2.
|
|
192
|
-
* 3.
|
|
193
|
-
* 4.
|
|
192
|
+
* 2. Convert content blocks; reject visibly-empty content
|
|
193
|
+
* 3. Deduplicate by eventId
|
|
194
|
+
* 4. Optionally check shouldTriggerInference callback
|
|
194
195
|
* 5. Push event to queue
|
|
195
196
|
* 6. Emit trace
|
|
196
197
|
* 7. Respond with accepted + inferenceId
|
|
@@ -229,30 +230,46 @@ export class PushHandler {
|
|
|
229
230
|
return;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
// 2. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
|
|
233
|
-
// coalescer's receipts instead (RFC-006 §3.1: a retry within the window
|
|
234
|
-
// gets its original result, which this set could not return).
|
|
235
233
|
const coalesced = params.coalesce !== undefined && !!this.handleCoalesced;
|
|
234
|
+
// Empty content is meaningful only for a coalescing retraction (pure
|
|
235
|
+
// withdrawal, RFC-006 §6 — the coalescer appends nothing for it) and for
|
|
236
|
+
// the silent-heartbeat marker. Anything else that shows the model nothing
|
|
237
|
+
// would wake it with no visible cause.
|
|
238
|
+
const emptyAllowed = (coalesced && params.coalesce?.retract === true)
|
|
239
|
+
|| isSilentHeartbeatMarker({ serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content });
|
|
236
240
|
if (coalesced) {
|
|
237
241
|
// RFC-006 §13: malformed content is a -32602, checked before conversion.
|
|
238
242
|
try {
|
|
239
|
-
validateCoalescedContent(params.payload?.content);
|
|
243
|
+
validateCoalescedContent(params.payload?.content, undefined, { allowEmpty: emptyAllowed });
|
|
240
244
|
} catch (error) {
|
|
241
245
|
const err = error as Error & { code?: number; field?: string };
|
|
246
|
+
if (err instanceof EmptyContentError) this.traceEmptyRejection(serverId, params);
|
|
242
247
|
if (responder?.respondError) responder.respondError(err.code ?? -32602, err.message, { field: err.field });
|
|
243
|
-
else responder?.respond({ accepted: false, reason: err.message });
|
|
248
|
+
else responder?.respond({ accepted: false, reason: err instanceof EmptyContentError ? err.reason : err.message });
|
|
244
249
|
return;
|
|
245
250
|
}
|
|
246
251
|
}
|
|
252
|
+
|
|
253
|
+
// 2. Convert content blocks, and refuse content with nothing visible in
|
|
254
|
+
// it — before dedup, so a refused eventId is not burned for a retry.
|
|
255
|
+
const content: ContentBlock[] = params.payload.content.map(convertBlock);
|
|
256
|
+
if (!emptyAllowed && isVisiblyEmptyContent(content)) {
|
|
257
|
+
const err = new EmptyContentError();
|
|
258
|
+
this.traceEmptyRejection(serverId, params);
|
|
259
|
+
if (responder?.respondError) responder.respondError(err.code, err.message, { field: err.field });
|
|
260
|
+
else responder?.respond({ accepted: false, reason: err.reason });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 3. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
|
|
265
|
+
// coalescer's receipts instead (RFC-006 §3.1: a retry within the window
|
|
266
|
+
// gets its original result, which this set could not return).
|
|
247
267
|
if (!coalesced && this.dedup.checkAndAdd(params.eventId)) {
|
|
248
268
|
console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
|
|
249
269
|
responder?.respond({ accepted: false, reason: 'duplicate' });
|
|
250
270
|
return;
|
|
251
271
|
}
|
|
252
272
|
|
|
253
|
-
// 3. Convert content blocks
|
|
254
|
-
const content: ContentBlock[] = params.payload.content.map(convertBlock);
|
|
255
|
-
|
|
256
273
|
// 4. Check shouldTriggerInference callback
|
|
257
274
|
let triggerInference = true;
|
|
258
275
|
if (this.shouldTriggerInference) {
|
|
@@ -316,4 +333,16 @@ export class PushHandler {
|
|
|
316
333
|
// 8. Respond
|
|
317
334
|
responder?.respond({ accepted: true, inferenceId });
|
|
318
335
|
}
|
|
336
|
+
|
|
337
|
+
/** Loud, like every other push rejection: the producer's wake went nowhere. */
|
|
338
|
+
private traceEmptyRejection(serverId: string, params: PushEventParams): void {
|
|
339
|
+
console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=empty-content`);
|
|
340
|
+
this.emitTraceFn({
|
|
341
|
+
type: 'mcpl:push-event-rejected',
|
|
342
|
+
serverId,
|
|
343
|
+
eventId: params.eventId,
|
|
344
|
+
featureSet: params.featureSet,
|
|
345
|
+
reason: 'empty-content',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
319
348
|
}
|
|
@@ -209,6 +209,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
209
209
|
* observable even though its original promise has already been rejected. */
|
|
210
210
|
private orphanedRequests = new Map<string | number, { method: string; renderParams?: Record<string, unknown> }>();
|
|
211
211
|
private closed = false;
|
|
212
|
+
private closePromise: Promise<void> | null = null;
|
|
213
|
+
private closeIncomplete = false;
|
|
214
|
+
private reconnectPromise: Promise<void> | null = null;
|
|
212
215
|
|
|
213
216
|
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
214
217
|
private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
@@ -837,10 +840,10 @@ export class McplServerConnection extends EventEmitter {
|
|
|
837
840
|
this.reconnectTimer = null;
|
|
838
841
|
}
|
|
839
842
|
|
|
840
|
-
if (this.
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
+
if (this.closePromise) return this.closePromise;
|
|
844
|
+
if (this.closed && !this.closeIncomplete && !this.reconnectPromise) return;
|
|
843
845
|
this.closed = true;
|
|
846
|
+
this.closeIncomplete = true;
|
|
844
847
|
this.resetPolicyForTransportBoundary();
|
|
845
848
|
|
|
846
849
|
// Reject all pending requests
|
|
@@ -854,11 +857,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
854
857
|
// Tear down the transport (kills the child / closes the socket). The
|
|
855
858
|
// transport's own 'close' event is short-circuited by `this.closed` in
|
|
856
859
|
// setupLifecycle, so this is the single source of the connection 'close'.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
860
|
+
const attempt = Promise.resolve().then(async () => {
|
|
861
|
+
// An already opened handshake owns a transport too. It hands that
|
|
862
|
+
// transport back without admission once reconnect has been disabled.
|
|
863
|
+
// The registry bounds this wait; retry reuses this real close attempt.
|
|
864
|
+
if (this.reconnectPromise) await this.reconnectPromise;
|
|
865
|
+
if (this.transport) await this.transport.close();
|
|
866
|
+
this.closeIncomplete = false;
|
|
867
|
+
this.emit('close');
|
|
868
|
+
});
|
|
869
|
+
this.closePromise = attempt;
|
|
870
|
+
void attempt.catch(() => {
|
|
871
|
+
if (this.closePromise === attempt) this.closePromise = null;
|
|
872
|
+
});
|
|
873
|
+
return attempt;
|
|
862
874
|
}
|
|
863
875
|
|
|
864
876
|
/**
|
|
@@ -912,8 +924,19 @@ export class McplServerConnection extends EventEmitter {
|
|
|
912
924
|
* approach this builds no throwaway connection instance, so there are no
|
|
913
925
|
* leaked listeners on a dead object.
|
|
914
926
|
*/
|
|
915
|
-
private
|
|
916
|
-
if (
|
|
927
|
+
private attemptReconnect(): Promise<void> {
|
|
928
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
929
|
+
const attempt = this.reconnectOnce();
|
|
930
|
+
this.reconnectPromise = attempt;
|
|
931
|
+
const clear = () => { if (this.reconnectPromise === attempt) this.reconnectPromise = null; };
|
|
932
|
+
void attempt.then(clear, clear);
|
|
933
|
+
return attempt;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private async reconnectOnce(): Promise<void> {
|
|
937
|
+
// A queued recovery timer may outlive an earlier successful handshake.
|
|
938
|
+
// Recheck disconnection before opening or replacing any owned transport.
|
|
939
|
+
if (!this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
|
|
917
940
|
|
|
918
941
|
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
919
942
|
// reports attempt N (reconnectAttempts counts failures so far).
|
|
@@ -925,6 +948,14 @@ export class McplServerConnection extends EventEmitter {
|
|
|
925
948
|
this.hostCapabilities,
|
|
926
949
|
);
|
|
927
950
|
|
|
951
|
+
if (!this.reconnectEnabled) {
|
|
952
|
+
// The prior transport was disconnected before this retry began. Keep
|
|
953
|
+
// the fresh, unadmitted transport owned by close(), including on a
|
|
954
|
+
// failed transport cleanup, instead of reviving a stopped connection.
|
|
955
|
+
this.transport = transport;
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
928
959
|
// Close the data plane before the new transport can emit. The reconnect
|
|
929
960
|
// lifecycle event bypasses buffering; its framework listener installs the
|
|
930
961
|
// awareness barrier synchronously, then releases control traffic needed
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { McplServerConfig, McplHostCapabilities } from './types.js';
|
|
8
8
|
import { McplServerConnection } from './server-connection.js';
|
|
9
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from '../shutdown.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Capability keys that can be queried via `getServersWithCapability`.
|
|
@@ -25,6 +26,7 @@ export type McplCapabilityQuery =
|
|
|
25
26
|
*/
|
|
26
27
|
export class McplServerRegistry {
|
|
27
28
|
private servers = new Map<string, McplServerConnection>();
|
|
29
|
+
private closing = new Map<McplServerConnection, Promise<void>>();
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Connect to an MCPL server and register it.
|
|
@@ -70,8 +72,7 @@ export class McplServerRegistry {
|
|
|
70
72
|
if (!connection) {
|
|
71
73
|
return;
|
|
72
74
|
}
|
|
73
|
-
this.
|
|
74
|
-
await connection.close();
|
|
75
|
+
await this.closeServer(id, connection);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
@@ -135,11 +136,27 @@ export class McplServerRegistry {
|
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
/**
|
|
138
|
-
* Close all server connections
|
|
139
|
+
* Close all server connections, retaining failed or pending attempts.
|
|
140
|
+
* An explicit timeout bounds the caller's wait without cancelling cleanup.
|
|
139
141
|
*/
|
|
140
|
-
async closeAll(): Promise<void> {
|
|
141
|
-
|
|
142
|
-
this.servers
|
|
143
|
-
|
|
142
|
+
async closeAll(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
|
|
143
|
+
validateShutdownTimeout(timeoutMs);
|
|
144
|
+
await waitForShutdown(Array.from(this.servers, ([id, connection]) => ({
|
|
145
|
+
label: `mcpl:${id}`, promise: this.closeServer(id, connection),
|
|
146
|
+
})), timeoutMs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private closeServer(id: string, connection: McplServerConnection): Promise<void> {
|
|
150
|
+
const existing = this.closing.get(connection);
|
|
151
|
+
if (existing) return existing;
|
|
152
|
+
const attempt = Promise.resolve().then(() => connection.close()).then(() => {
|
|
153
|
+
if (this.servers.get(id) === connection) this.servers.delete(id);
|
|
154
|
+
this.closing.delete(connection);
|
|
155
|
+
}, error => {
|
|
156
|
+
this.closing.delete(connection);
|
|
157
|
+
throw error;
|
|
158
|
+
});
|
|
159
|
+
this.closing.set(connection, attempt);
|
|
160
|
+
return attempt;
|
|
144
161
|
}
|
|
145
162
|
}
|
|
@@ -50,8 +50,20 @@ export interface ToolLifecycleParams {
|
|
|
50
50
|
inputWithheld?: boolean;
|
|
51
51
|
isError?: boolean;
|
|
52
52
|
durationMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* MCP's extension slot. Carries only SCRIPT_PARENT_META_KEY, on calls a
|
|
55
|
+
* code_execution script made: RFC-007 has no field for a call's origin,
|
|
56
|
+
* and the `mcpl/` prefix is reserved for keys MCPL defines (RFC-008 §3).
|
|
57
|
+
*/
|
|
58
|
+
_meta?: Record<string, unknown>;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* `_meta` key naming the `toolCallId` of the code_execution call whose script
|
|
63
|
+
* made this call. Host-specific: not defined by RFC-007.
|
|
64
|
+
*/
|
|
65
|
+
export const SCRIPT_PARENT_META_KEY = 'agent-framework/parentToolCallId';
|
|
66
|
+
|
|
55
67
|
export interface ToolObserveMatch {
|
|
56
68
|
tool?: string;
|
|
57
69
|
serverTool?: string;
|
|
@@ -78,7 +90,8 @@ export interface ToolObserveRule {
|
|
|
78
90
|
* RFC-007 §6.2 grammar.
|
|
79
91
|
*/
|
|
80
92
|
export interface ToolLifecycleNarrowing {
|
|
81
|
-
/** Patterns over the model-facing tool name (`computer--*`)
|
|
93
|
+
/** Patterns over the model-facing tool name (`computer--*`): for an MCPL
|
|
94
|
+
* tool `<toolPrefix>--<tool>`, toolPrefix defaulting to `mcpl--<serverId>`. */
|
|
82
95
|
tools?: string[];
|
|
83
96
|
/** The tool's effective class must intersect this set. `'default'` is
|
|
84
97
|
* DEFAULT_INPUT_CLASSES (computer, shell, files, web, media, body). */
|
|
@@ -242,6 +255,8 @@ export interface ToolCallDescriptor {
|
|
|
242
255
|
serverId?: string;
|
|
243
256
|
serverTool?: string;
|
|
244
257
|
input: unknown;
|
|
258
|
+
/** Script-inner calls: the code_execution call's `toolCallId`, when it had one. */
|
|
259
|
+
parentToolCallId?: string;
|
|
245
260
|
}
|
|
246
261
|
|
|
247
262
|
function anyGlob(patterns: readonly string[], subject: string): boolean {
|
|
@@ -466,6 +481,7 @@ function baseParams(call: ToolCallDescriptor, phase: ToolPhase): ToolLifecyclePa
|
|
|
466
481
|
class: [...call.class],
|
|
467
482
|
...(call.serverId !== undefined ? { serverId: call.serverId, serverTool: call.serverTool } : {}),
|
|
468
483
|
phase,
|
|
484
|
+
...(call.parentToolCallId !== undefined ? { _meta: { [SCRIPT_PARENT_META_KEY]: call.parentToolCallId } } : {}),
|
|
469
485
|
};
|
|
470
486
|
}
|
|
471
487
|
|
|
@@ -537,10 +553,23 @@ export interface ToolLifecycleHost {
|
|
|
537
553
|
describe(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string };
|
|
538
554
|
}
|
|
539
555
|
|
|
556
|
+
/**
|
|
557
|
+
* What a call made from inside a code_execution script inherits from the
|
|
558
|
+
* code_execution call that ran the script (scriptOrigin()).
|
|
559
|
+
*/
|
|
560
|
+
export interface ScriptCallOrigin {
|
|
561
|
+
/** The inference whose output requested the code_execution call. */
|
|
562
|
+
inferenceId: string;
|
|
563
|
+
/** That call's `toolCallId`. Absent when nothing observed it, so it has none. */
|
|
564
|
+
parentToolCallId?: string;
|
|
565
|
+
}
|
|
566
|
+
|
|
540
567
|
interface TrackedCall {
|
|
541
568
|
agentName: string;
|
|
542
569
|
modelCallId: string;
|
|
543
570
|
call: ToolCallDescriptor;
|
|
571
|
+
/** Made by a code_execution script, not by the model (registerScriptCall). */
|
|
572
|
+
scriptInner: boolean;
|
|
544
573
|
/** Connection id → transport epoch the opening went to. */
|
|
545
574
|
openedTo: Map<string, number>;
|
|
546
575
|
/** `started` has been sent (open() ran and the call was not refused). */
|
|
@@ -569,6 +598,9 @@ export class ToolLifecycleEmitter {
|
|
|
569
598
|
private readonly recentIds = new Set<string>();
|
|
570
599
|
private readonly recentOrder: string[] = [];
|
|
571
600
|
private mintSeq = 0;
|
|
601
|
+
/** The call between register() and open(), kept even when nothing observes,
|
|
602
|
+
* so a code_execution dispatch can learn its inference (scriptOrigin()). */
|
|
603
|
+
private dispatching: { agentName: string; callId: string; inferenceId: string } | null = null;
|
|
572
604
|
|
|
573
605
|
constructor(host: ToolLifecycleHost, now: () => number = Date.now) {
|
|
574
606
|
this.host = host;
|
|
@@ -608,6 +640,44 @@ export class ToolLifecycleEmitter {
|
|
|
608
640
|
* host may still refuse it (refuse()) before it executes.
|
|
609
641
|
*/
|
|
610
642
|
register(agentName: string, inferenceId: string, call: { id: string; name: string; input: unknown }): void {
|
|
643
|
+
this.dispatching = { agentName, callId: call.id, inferenceId };
|
|
644
|
+
this.track(agentName, inferenceId, call, false);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* A call a code_execution script made, about to go through the same
|
|
649
|
+
* dispatch as a model call (then refuse() / open() / onResult() as for
|
|
650
|
+
* one). Its class, provider, grant narrowing and filter matching are the
|
|
651
|
+
* INNER tool's own; its inference is the one that issued the
|
|
652
|
+
* code_execution call. Unlike a model call it is not aborted when its
|
|
653
|
+
* stream ends: the tool keeps running after the turn or the script is
|
|
654
|
+
* gone, so its terminal is the result's, or `failed` when the host gives
|
|
655
|
+
* up waiting for one.
|
|
656
|
+
*/
|
|
657
|
+
registerScriptCall(agentName: string, origin: ScriptCallOrigin, call: { id: string; name: string; input: unknown }): void {
|
|
658
|
+
this.track(agentName, origin.inferenceId, call, true, origin.parentToolCallId);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* The origin a code_execution call passes to the calls its script makes.
|
|
663
|
+
* Valid only while that call is being dispatched (between register() and
|
|
664
|
+
* open()), which is where the framework asks.
|
|
665
|
+
*/
|
|
666
|
+
scriptOrigin(agentName: string, callId: string): ScriptCallOrigin | undefined {
|
|
667
|
+
const tracked = this.calls.get(callKey(agentName, callId));
|
|
668
|
+
if (tracked) return { inferenceId: tracked.call.inferenceId, parentToolCallId: tracked.call.toolCallId };
|
|
669
|
+
const d = this.dispatching;
|
|
670
|
+
if (d && d.agentName === agentName && d.callId === callId) return { inferenceId: d.inferenceId };
|
|
671
|
+
return undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private track(
|
|
675
|
+
agentName: string,
|
|
676
|
+
inferenceId: string,
|
|
677
|
+
call: { id: string; name: string; input: unknown },
|
|
678
|
+
scriptInner: boolean,
|
|
679
|
+
parentToolCallId?: string,
|
|
680
|
+
): void {
|
|
611
681
|
if (!this.anyObserver()) return;
|
|
612
682
|
const described = this.host.describe(call.name);
|
|
613
683
|
this.calls.set(callKey(agentName, call.id), {
|
|
@@ -623,7 +693,9 @@ export class ToolLifecycleEmitter {
|
|
|
623
693
|
? { serverId: described.serverId, serverTool: described.serverTool }
|
|
624
694
|
: {}),
|
|
625
695
|
input: call.input,
|
|
696
|
+
...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
|
|
626
697
|
},
|
|
698
|
+
scriptInner,
|
|
627
699
|
openedTo: new Map(),
|
|
628
700
|
opened: false,
|
|
629
701
|
// Taken before dispatch, so durationMs includes work a synchronous
|
|
@@ -652,6 +724,7 @@ export class ToolLifecycleEmitter {
|
|
|
652
724
|
* run synchronously inside dispatch, before this.
|
|
653
725
|
*/
|
|
654
726
|
open(agentName: string, callId: string): void {
|
|
727
|
+
this.dispatching = null;
|
|
655
728
|
const key = callKey(agentName, callId);
|
|
656
729
|
const tracked = this.calls.get(key);
|
|
657
730
|
if (!tracked || tracked.opened) return;
|
|
@@ -725,12 +798,14 @@ export class ToolLifecycleEmitter {
|
|
|
725
798
|
* A stream ended: its calls still open were cancelled before a result.
|
|
726
799
|
* Scoped to the inference ids that stream minted, so a successor stream
|
|
727
800
|
* for the same agent (a budget restart overlapping this one's teardown)
|
|
728
|
-
* keeps its live calls. A result arriving later finds nothing.
|
|
801
|
+
* keeps its live calls. A result arriving later finds nothing. Calls a
|
|
802
|
+
* script made are left open: they end with their own result.
|
|
729
803
|
*/
|
|
730
804
|
abortOpen(agentName: string, inferenceIds: Iterable<string>): void {
|
|
731
805
|
if (this.calls.size === 0) return;
|
|
732
806
|
const ids = new Set(inferenceIds);
|
|
733
807
|
for (const tracked of [...this.calls.values()]) {
|
|
808
|
+
if (tracked.scriptInner) continue;
|
|
734
809
|
if (tracked.agentName === agentName && ids.has(tracked.call.inferenceId)) {
|
|
735
810
|
if (tracked.opened) this.terminate(tracked, 'aborted');
|
|
736
811
|
else this.calls.delete(callKey(tracked.agentName, tracked.modelCallId));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-match diagnostics for operator tool-name patterns.
|
|
3
|
+
*
|
|
4
|
+
* `toolClassOverrides` keys and `toolLifecycle.{observe,inputs}.tools` are
|
|
5
|
+
* RFC-007 §6.2 patterns over the MODEL-FACING tool name. For an MCPL tool
|
|
6
|
+
* that is `<toolPrefix>--<tool>`, and `toolPrefix` defaults to
|
|
7
|
+
* `mcpl--<serverId>`. A pattern written against the bare server id
|
|
8
|
+
* (`search--*` for server `search` with no toolPrefix) is valid config that
|
|
9
|
+
* matches nothing, and nothing else reports it: the override classes no tool
|
|
10
|
+
* and the narrowing admits no call. This module decides, for one pattern and
|
|
11
|
+
* the current tool listing, whether that is the case and what was probably
|
|
12
|
+
* meant.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { globMatch } from './tool-glob.js';
|
|
16
|
+
|
|
17
|
+
export interface PatternServer {
|
|
18
|
+
id: string;
|
|
19
|
+
/** Effective tool prefix: config `toolPrefix ?? mcpl--<id>`. */
|
|
20
|
+
prefix: string;
|
|
21
|
+
/** True when this server's tools are in the current listing. */
|
|
22
|
+
listed: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ToolPatternVerdict =
|
|
26
|
+
| { kind: 'matched' }
|
|
27
|
+
/** Could still match tools of servers whose listing is not in (yet). */
|
|
28
|
+
| { kind: 'pending'; servers: string[] }
|
|
29
|
+
| { kind: 'unmatched'; suggestion?: string; hint: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Could `pattern` match some name under `<prefix>--`? Decided on the literal
|
|
33
|
+
* text before the first `*`; anything after it is not consulted, so this
|
|
34
|
+
* errs toward "could" (which only defers a warning, never invents one).
|
|
35
|
+
*/
|
|
36
|
+
export function couldMatchUnderPrefix(pattern: string, prefix: string): boolean {
|
|
37
|
+
const head = `${prefix}--`;
|
|
38
|
+
const star = pattern.indexOf('*');
|
|
39
|
+
if (star === -1) return pattern.startsWith(head);
|
|
40
|
+
const literal = pattern.slice(0, star);
|
|
41
|
+
return literal.startsWith(head) || head.startsWith(literal);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check one pattern against the model-facing tool names offered right now.
|
|
46
|
+
*
|
|
47
|
+
* `pending` while a server whose tools the pattern could name has not
|
|
48
|
+
* listed them (not connected yet, reconnecting, or removed): its listing is
|
|
49
|
+
* the only evidence, and an early verdict would be a false alarm.
|
|
50
|
+
*/
|
|
51
|
+
export function checkToolPattern(
|
|
52
|
+
pattern: string,
|
|
53
|
+
toolNames: Iterable<string>,
|
|
54
|
+
servers: readonly PatternServer[],
|
|
55
|
+
): ToolPatternVerdict {
|
|
56
|
+
const names = [...toolNames];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (globMatch(pattern, name)) return { kind: 'matched' };
|
|
59
|
+
}
|
|
60
|
+
const pending = servers.filter((s) => !s.listed && couldMatchUnderPrefix(pattern, s.prefix));
|
|
61
|
+
if (pending.length > 0) return { kind: 'pending', servers: [...new Set(pending.map((s) => s.id))] };
|
|
62
|
+
|
|
63
|
+
// Written against the wrong namespace for a known server: the bare id
|
|
64
|
+
// (`<id>--…`) where the effective prefix differs, or the default form
|
|
65
|
+
// (`mcpl--<id>--…`) for a server that set its own toolPrefix. Never when
|
|
66
|
+
// the namespace as written is real — another server's toolPrefix or a
|
|
67
|
+
// module's tools — since the pattern then names that, not a mistake.
|
|
68
|
+
const inRealNamespace = (ns: string) =>
|
|
69
|
+
servers.some((o) => o.prefix === ns) || names.some((n) => n.startsWith(`${ns}--`));
|
|
70
|
+
for (const s of servers) {
|
|
71
|
+
let rest: string | undefined;
|
|
72
|
+
if (s.prefix !== s.id && pattern.startsWith(`${s.id}--`) && !inRealNamespace(s.id)) {
|
|
73
|
+
rest = pattern.slice(s.id.length + 2);
|
|
74
|
+
} else if (
|
|
75
|
+
s.prefix !== `mcpl--${s.id}` && pattern.startsWith(`mcpl--${s.id}--`) && !inRealNamespace(`mcpl--${s.id}`)
|
|
76
|
+
) {
|
|
77
|
+
rest = pattern.slice(s.id.length + 8);
|
|
78
|
+
}
|
|
79
|
+
if (rest === undefined) continue;
|
|
80
|
+
const why = s.prefix === `mcpl--${s.id}` ? ' (no toolPrefix set; the default is "mcpl--<serverId>")' : '';
|
|
81
|
+
return {
|
|
82
|
+
kind: 'unmatched',
|
|
83
|
+
suggestion: `${s.prefix}--${rest}`,
|
|
84
|
+
hint: `server "${s.id}" names its tools "${s.prefix}--<tool>"${why}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (pattern.includes('--') && !servers.some((s) => couldMatchUnderPrefix(pattern, s.prefix))) {
|
|
89
|
+
return { kind: 'unmatched', hint: 'no connected MCPL server has a toolPrefix it could match' };
|
|
90
|
+
}
|
|
91
|
+
return { kind: 'unmatched', hint: 'no tool the framework offers has a matching name' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The operator-facing line for an `unmatched` verdict. */
|
|
95
|
+
export function describeUnmatchedPattern(
|
|
96
|
+
where: string,
|
|
97
|
+
pattern: string,
|
|
98
|
+
verdict: Extract<ToolPatternVerdict, { kind: 'unmatched' }>,
|
|
99
|
+
): string {
|
|
100
|
+
const meant = verdict.suggestion ? ` — did you mean ${JSON.stringify(verdict.suggestion)}?` : '';
|
|
101
|
+
return (
|
|
102
|
+
`[mcpl] ${where} pattern ${JSON.stringify(pattern)} matches no tool${meant} ` +
|
|
103
|
+
`(${verdict.hint}). Patterns match the full model-facing tool name, ` +
|
|
104
|
+
`"<toolPrefix>--<tool>" for MCPL tools.`
|
|
105
|
+
);
|
|
106
|
+
}
|