@animalabs/agent-framework 0.26.1 → 0.27.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 +57 -7
- package/dist/src/agent.d.ts +263 -22
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +531 -54
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +1 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +33 -0
- package/dist/src/auto-continue.d.ts.map +1 -0
- package/dist/src/auto-continue.js +35 -0
- package/dist/src/auto-continue.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -1
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/framework.d.ts +829 -156
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3906 -1008
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +309 -46
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +2117 -395
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +2 -2
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts +103 -0
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
- package/dist/src/gate/recipe-reconcile.js +325 -0
- package/dist/src/gate/recipe-reconcile.js.map +1 -0
- package/dist/src/gate/types.d.ts +68 -8
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +433 -113
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1152 -434
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +16 -8
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +29 -15
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +14 -2
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +32 -7
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +4 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +14 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +73 -28
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +6 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +3 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +20 -2
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +33 -11
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +177 -145
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +380 -46
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +737 -78
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +82 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +132 -7
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +21 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +259 -0
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
- package/dist/src/mcpl/tool-presence.js +628 -0
- package/dist/src/mcpl/tool-presence.js.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js +14 -6
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +39 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
- package/dist/src/mcpl/tool-signature.js +292 -0
- package/dist/src/mcpl/tool-signature.js.map +1 -0
- package/dist/src/mcpl/toolset-host.d.ts +200 -0
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
- package/dist/src/mcpl/toolset-host.js +621 -0
- package/dist/src/mcpl/toolset-host.js.map +1 -0
- package/dist/src/mcpl/toolsets-module.d.ts +80 -0
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
- package/dist/src/mcpl/toolsets-module.js +431 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +2 -2
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +3 -2
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +21 -1
- 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 +7 -9
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +21 -9
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +23 -8
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +68 -15
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +2 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +51 -17
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +108 -6
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +273 -57
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +181 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
- package/dist/src/modules/workspace/direct-write.js +384 -0
- package/dist/src/modules/workspace/direct-write.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +26 -12
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +58 -28
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +10 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +1 -1
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +246 -27
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1216 -236
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts +41 -0
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
- package/dist/src/modules/workspace/read-page.js +213 -0
- package/dist/src/modules/workspace/read-page.js.map +1 -0
- package/dist/src/modules/workspace/result-text.d.ts +67 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/result-text.js +94 -0
- package/dist/src/modules/workspace/result-text.js.map +1 -0
- package/dist/src/modules/workspace/text.d.ts +29 -12
- package/dist/src/modules/workspace/text.d.ts.map +1 -1
- package/dist/src/modules/workspace/text.js +35 -33
- package/dist/src/modules/workspace/text.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +41 -17
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/sent-rows.d.ts +59 -0
- package/dist/src/sent-rows.d.ts.map +1 -0
- package/dist/src/sent-rows.js +145 -0
- package/dist/src/sent-rows.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +17 -10
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stable-hash.d.ts +3 -0
- package/dist/src/stable-hash.d.ts.map +1 -0
- package/dist/src/stable-hash.js +15 -0
- package/dist/src/stable-hash.js.map +1 -0
- package/dist/src/stored-injections.d.ts +99 -0
- package/dist/src/stored-injections.d.ts.map +1 -0
- package/dist/src/stored-injections.js +106 -0
- package/dist/src/stored-injections.js.map +1 -0
- package/dist/src/text-as-stored.d.ts +4 -3
- package/dist/src/text-as-stored.d.ts.map +1 -1
- package/dist/src/text-as-stored.js +4 -3
- package/dist/src/text-as-stored.js.map +1 -1
- package/dist/src/thinking-drops.d.ts +81 -0
- package/dist/src/thinking-drops.d.ts.map +1 -0
- package/dist/src/thinking-drops.js +103 -0
- package/dist/src/thinking-drops.js.map +1 -0
- package/dist/src/tool-image-ledger.d.ts +9 -8
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +9 -8
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +3 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +4 -2
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +21 -6
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +186 -21
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +428 -89
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +17 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +84 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +186 -7
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +19 -2
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +24 -3
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +90 -7
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +57 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/agent.ts +593 -60
- package/src/api/server.ts +1 -2
- package/src/auto-continue.ts +42 -0
- package/src/code-execution/isolate/scripts.ts +11 -3
- package/src/framework.ts +4004 -1026
- package/src/gate/event-gate.ts +2315 -389
- package/src/gate/index.ts +2 -1
- package/src/gate/recipe-reconcile.ts +355 -0
- package/src/gate/types.ts +70 -8
- package/src/index.ts +7 -4
- package/src/mcpl/channel-registry.ts +1220 -467
- package/src/mcpl/conversation-router.ts +34 -16
- package/src/mcpl/convert-block.ts +14 -1
- package/src/mcpl/hook-orchestrator.ts +33 -6
- package/src/mcpl/index.ts +6 -1
- package/src/mcpl/inference-router.ts +79 -34
- package/src/mcpl/prose-grammar.ts +7 -0
- package/src/mcpl/protocol-family.ts +2 -0
- package/src/mcpl/push-coalescer.ts +51 -13
- package/src/mcpl/reference-fetcher.ts +201 -162
- package/src/mcpl/references.ts +852 -78
- package/src/mcpl/server-connection.ts +145 -7
- package/src/mcpl/tool-lifecycle.ts +21 -0
- package/src/mcpl/tool-presence.ts +650 -0
- package/src/mcpl/tool-result-normalize.ts +18 -6
- package/src/mcpl/tool-signature.ts +285 -0
- package/src/mcpl/toolset-host.ts +637 -0
- package/src/mcpl/toolsets-module.ts +451 -0
- package/src/mcpl/transport.ts +3 -2
- package/src/mcpl/types.ts +22 -1
- package/src/mcpl/visible-content.ts +22 -9
- package/src/module-registry.ts +77 -24
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +48 -18
- package/src/modules/history/semantic.ts +308 -54
- package/src/modules/workspace/direct-write.ts +425 -0
- package/src/modules/workspace/edit-text.ts +82 -35
- package/src/modules/workspace/effects.ts +1 -1
- package/src/modules/workspace/index.ts +1274 -241
- package/src/modules/workspace/read-page.ts +222 -0
- package/src/modules/workspace/result-text.ts +119 -0
- package/src/modules/workspace/text.ts +58 -32
- package/src/modules/workspace/types.ts +41 -17
- package/src/sent-rows.ts +162 -0
- package/src/silent-heartbeat.ts +17 -10
- package/src/stable-hash.ts +14 -0
- package/src/stored-injections.ts +131 -0
- package/src/text-as-stored.ts +4 -3
- package/src/thinking-drops.ts +141 -0
- package/src/tool-image-ledger.ts +9 -8
- package/src/tool-presentation.ts +3 -1
- package/src/tool-result-guard.ts +19 -6
- package/src/tool-result-history.ts +488 -88
- package/src/tune-out/coordinator.ts +80 -37
- package/src/types/agent.ts +189 -7
- package/src/types/events.ts +19 -2
- package/src/types/framework.ts +21 -3
- package/src/types/module.ts +86 -7
- package/src/types/trace.ts +58 -2
package/src/sent-rows.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ContentBlock, ToolResult as MembraneToolResult } from '@animalabs/membrane';
|
|
2
|
+
import type { MessageId } from '@animalabs/context-manager';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
|
|
5
|
+
import { withoutTokenEstimates } from './thinking-token-stamp.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A row a stream's later round carried after its compiled messages, as a context manager that keeps a
|
|
9
|
+
* thinking-binding record is told of it (`noteRequestSent`): the id of a stored body the next request carries as
|
|
10
|
+
* the stream sent it, or the marker for one it doesn't, after which nothing keeps its thinking.
|
|
11
|
+
*/
|
|
12
|
+
export type SentRow = MessageId | { unstored: true };
|
|
13
|
+
|
|
14
|
+
const UNSTORED: SentRow = Object.freeze({ unstored: true as const });
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a request carries a block: the sanitize pass leaves out an empty or whitespace text block, which the
|
|
18
|
+
* Anthropic API refuses (assembleActivationRequest). The `typeof` guard is runtime defense: history loaded from
|
|
19
|
+
* disk can carry a non-string `text` despite what the types claim.
|
|
20
|
+
*/
|
|
21
|
+
export function carriedBlock(block: ContentBlock): boolean {
|
|
22
|
+
return !(block.type === 'text' && (typeof block.text !== 'string' || block.text.trim() === ''));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A stored body's content as the agent's next request carries it: what the sanitize pass leaves of it, without
|
|
27
|
+
* the framework's pricing stamp. Null when the pass leaves nothing, so the request carries no message there.
|
|
28
|
+
*/
|
|
29
|
+
export function asNextRequestCarries(content: readonly ContentBlock[]): ContentBlock[] | null {
|
|
30
|
+
const kept = content.filter(carriedBlock);
|
|
31
|
+
if (kept.length === 0) return null;
|
|
32
|
+
return withoutTokenEstimates([{ content: kept }])[0]!.content;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a block of what a stream sent counts in the comparison. membrane's native formatter leaves out an exactly
|
|
37
|
+
* empty text block, which the API refuses. An empty text block carrying a provider-native item (`rawItem`) it sends
|
|
38
|
+
* as that item where it's a server-tool block it knows, in an assistant turn, and leaves out otherwise; whitespace
|
|
39
|
+
* text it sends, behind the participant's name where a multiuser conversation shows names. The next request's
|
|
40
|
+
* sanitize pass leaves both out, so both count as differences. Where membrane, or the adapter's cleanup (whitespace in
|
|
41
|
+
* an assistant turn), leaves one out of both requests, that's a needless strip rather than a row named for bytes the
|
|
42
|
+
* provider didn't see.
|
|
43
|
+
*/
|
|
44
|
+
function sentBlock(block: ContentBlock): boolean {
|
|
45
|
+
if (block.type !== 'text' || block.text !== '') return true;
|
|
46
|
+
const raw = (block as { rawItem?: unknown }).rawItem;
|
|
47
|
+
return raw !== null && typeof raw === 'object';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A block's own cache marker left out: the provider's thinking binding doesn't count it, nor does the context
|
|
51
|
+
* manager's record, and stored history can carry a stale one. */
|
|
52
|
+
function withoutCacheMarker(block: ContentBlock): unknown {
|
|
53
|
+
if (!('cache_control' in block) && !('cacheControl' in block)) return block;
|
|
54
|
+
const { cache_control: _snake, cacheControl: _camel, ...rest } = block as ContentBlock & { cache_control?: unknown; cacheControl?: unknown };
|
|
55
|
+
return rest;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A value as JSON writes it: a non-finite number as null, and undefined, a function or a symbol as nothing
|
|
59
|
+
* (undefined here), which an object leaves out and an array writes as null. */
|
|
60
|
+
function asJson(value: unknown): unknown {
|
|
61
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
62
|
+
return value === undefined || typeof value === 'function' || typeof value === 'symbol' ? undefined : value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether JSON writes an object by walking it: an array, or a plain object without its own `toJSON`. */
|
|
66
|
+
function walkable(value: object): boolean {
|
|
67
|
+
if (Array.isArray(value)) return true;
|
|
68
|
+
const proto = Object.getPrototypeOf(value);
|
|
69
|
+
return (proto === Object.prototype || proto === null) && typeof (value as { toJSON?: unknown }).toJSON !== 'function';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Blocks equal as JSON data, as the store keeps them and a request sends them: equal where `JSON.stringify` writes
|
|
74
|
+
* them alike, key order aside. So a non-finite number is null; a field whose value is undefined, a function or a
|
|
75
|
+
* symbol is absent; such an array element, or a hole, is null; and anything else an object can be (a Date, a Buffer)
|
|
76
|
+
* is compared as JSON writes it. It walks plain objects and arrays rather than serializing them, since a note compares
|
|
77
|
+
* every row of the stream so far, base64 images and long tool results included, at each boundary.
|
|
78
|
+
*/
|
|
79
|
+
export function sameData(a: unknown, b: unknown): boolean {
|
|
80
|
+
const left = asJson(a);
|
|
81
|
+
const right = asJson(b);
|
|
82
|
+
if (left === right) return true;
|
|
83
|
+
const unwalked = (value: unknown) => typeof value === 'object' && value !== null && !walkable(value);
|
|
84
|
+
if (unwalked(left) || unwalked(right)) {
|
|
85
|
+
return isDeepStrictEqual(JSON.parse(JSON.stringify(left) ?? 'null'), JSON.parse(JSON.stringify(right) ?? 'null'));
|
|
86
|
+
}
|
|
87
|
+
if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false;
|
|
88
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
89
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
90
|
+
for (let i = 0; i < left.length; i++) {
|
|
91
|
+
if (!sameData(asJson(left[i]) ?? null, asJson(right[i]) ?? null)) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const l = left as Record<string, unknown>;
|
|
96
|
+
const r = right as Record<string, unknown>;
|
|
97
|
+
const keys = Object.keys(l).filter((key) => asJson(l[key]) !== undefined);
|
|
98
|
+
return keys.length === Object.keys(r).filter((key) => asJson(r[key]) !== undefined).length
|
|
99
|
+
&& keys.every((key) => Object.hasOwn(r, key) && asJson(r[key]) !== undefined && sameData(l[key], r[key]));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What membrane builds a round's results message from: each submitted result as a `tool_result` block. */
|
|
103
|
+
function submittedBlocks(results: readonly MembraneToolResult[]): ContentBlock[] {
|
|
104
|
+
return results.map((result) => ({
|
|
105
|
+
type: 'tool_result',
|
|
106
|
+
toolUseId: result.toolUseId,
|
|
107
|
+
content: result.content,
|
|
108
|
+
isError: result.isError === true,
|
|
109
|
+
}) as ContentBlock);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One stored row of a round, beside what the stream sent in its place. */
|
|
113
|
+
export type RoundRow =
|
|
114
|
+
| { kind: 'reply'; id: MessageId | undefined; sent: readonly ContentBlock[] }
|
|
115
|
+
| { kind: 'results'; id: MessageId | undefined; sent: readonly MembraneToolResult[] }
|
|
116
|
+
| { kind: 'injected'; id: MessageId | undefined; participant: string; sent: readonly ContentBlock[] };
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A round's rows as noted: each stored row's id where the next request carries it as the stream sent it, the
|
|
120
|
+
* marker where it doesn't. `carried` gives each id's stored body as the next compile renders it (participant and
|
|
121
|
+
* content, or null where no compile can send it), so the comparison is the one the context manager's record
|
|
122
|
+
* makes, after the agent's own request passes (asNextRequestCarries). A row the store doesn't hold (no id), a reply
|
|
123
|
+
* that differs from the round's content as membrane held it, results that differ from those submitted (as while the
|
|
124
|
+
* tool-result guard holds a batch, storing placeholders and submitting the originals), or an injected message sent
|
|
125
|
+
* otherwise than its stored body renders, is the marker. Called again over the same rows once the store has
|
|
126
|
+
* changed, as when the guard's batch settles to the originals, it gives each row's verdict as of then.
|
|
127
|
+
*/
|
|
128
|
+
export function roundRows(
|
|
129
|
+
rows: readonly RoundRow[],
|
|
130
|
+
carried: (id: MessageId) => { participant: string; content: ContentBlock[] } | null,
|
|
131
|
+
): SentRow[] {
|
|
132
|
+
return rows.map((row): SentRow => {
|
|
133
|
+
if (row.id === undefined) return UNSTORED;
|
|
134
|
+
const body = carried(row.id);
|
|
135
|
+
const content = body ? asNextRequestCarries(body.content) : null;
|
|
136
|
+
if (!body || !content) return UNSTORED;
|
|
137
|
+
// What the stream sent, as membrane's native formatter sends it (sentBlock): an exactly empty text block is in
|
|
138
|
+
// neither request.
|
|
139
|
+
const sent = row.kind === 'results' ? submittedBlocks(row.sent) : row.sent.filter(sentBlock);
|
|
140
|
+
const sameParticipant = row.kind !== 'injected' || body.participant === row.participant;
|
|
141
|
+
const sameContent = row.kind === 'results'
|
|
142
|
+
? sameData(content.map(normalizedResult), sent.map(normalizedResult))
|
|
143
|
+
: sameData(content.map(withoutCacheMarker), sent.map(withoutCacheMarker));
|
|
144
|
+
return sameParticipant && sameContent ? row.id : UNSTORED;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A results block as compared: what a request sends of it, its id, content and error flag, with `isError` as a
|
|
150
|
+
* boolean, where a stored block may leave a false one out. A stream's envelope sends a submitted result's
|
|
151
|
+
* `is_error: false` and the next request, from the stored block, sends none; the provider's thinking binding takes
|
|
152
|
+
* the two alike (read from the provider on 2026-10-23: Haiku 5.5 with the drop control, on an enforcing API-key
|
|
153
|
+
* organization and on a subscription account, reported no drop for either, and dropped both replies' blocks when an
|
|
154
|
+
* earlier user message changed). The store's own fields beside them, such as
|
|
155
|
+
* `toolName`, don't reach the provider on membrane's native path, the only one noted (membrane 0.7.1's XML
|
|
156
|
+
* formatter reads `toolName`, and an XML stream is never noted).
|
|
157
|
+
*/
|
|
158
|
+
function normalizedResult(block: ContentBlock): unknown {
|
|
159
|
+
if (block.type !== 'tool_result') return block;
|
|
160
|
+
const { toolUseId, content, isError } = block as { toolUseId?: unknown; content?: unknown; isError?: unknown };
|
|
161
|
+
return { type: 'tool_result', toolUseId, content, isError: isError === true };
|
|
162
|
+
}
|
package/src/silent-heartbeat.ts
CHANGED
|
@@ -7,19 +7,22 @@
|
|
|
7
7
|
* - A silent heartbeat tick stores no prompt row (#216): its reply is written
|
|
8
8
|
* straight after whatever the window held before it — often the previous
|
|
9
9
|
* assistant message, since a suppressed tick leaves no delivery receipt
|
|
10
|
-
* behind. Every row
|
|
11
|
-
* and a separator carrying the tick's
|
|
12
|
-
* first of
|
|
10
|
+
* behind. Every row the tick's reply writes is stamped with
|
|
11
|
+
* `metadata.silentHeartbeat`, and a separator carrying the tick's
|
|
12
|
+
* instruction is rendered before the first of those rows that survives
|
|
13
|
+
* compilation. A context injection its start stores (stored-injections.ts)
|
|
14
|
+
* is unstamped: it's history the agent saw, not the tick's speech, so a
|
|
15
|
+
* policy that drops a tick's rows leaves it.
|
|
13
16
|
* - A compile that ends on the agent's own message gets a trailing
|
|
14
17
|
* `[Continue]` turn (some models reject a trailing assistant message). The
|
|
15
18
|
* reply it prompts is stored straight after that message, so the reply's
|
|
16
19
|
* rows are stamped `metadata.promptedBy` (`RequestOnlyPrompt`), and the turn
|
|
17
20
|
* is rendered before the first of them that survives. A reply follows the
|
|
18
21
|
* prompt when what it was sent ends on that `[Continue]`, or on the tool
|
|
19
|
-
* results of a reply that followed it (`toolLoopPrompt`)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* prompt.
|
|
22
|
+
* results of a reply that followed it (`toolLoopPrompt`), stored context
|
|
23
|
+
* injections after those results aside: a stream start and each round of
|
|
24
|
+
* a stream alike. A reply sent anything else after the results (a message
|
|
25
|
+
* that arrived meanwhile) follows that, and carries no prompt.
|
|
23
26
|
*
|
|
24
27
|
* Without the row, the wire formatter merges consecutive same-role messages:
|
|
25
28
|
* the reply would read as an unprompted continuation of the earlier response,
|
|
@@ -75,9 +78,13 @@ export interface SilentHeartbeatTick {
|
|
|
75
78
|
serverId: string;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
/** `metadata.silentHeartbeat` on
|
|
79
|
-
*
|
|
80
|
-
* (
|
|
81
|
+
/** `metadata.silentHeartbeat` on the rows of a tick's own turn (its reply,
|
|
82
|
+
* tool results and their wrappers). What the framework stores of its own
|
|
83
|
+
* accord is left unstamped, a clock row (at the turn's start or a tool
|
|
84
|
+
* boundary) or a context injection, so a retention policy that drops a
|
|
85
|
+
* tick's rows keeps it. Residents share one message
|
|
86
|
+
* slot, so the stamp names the agent whose turn wrote it (tool_result rows
|
|
87
|
+
* are stored as `user`). */
|
|
81
88
|
export interface SilentHeartbeatStamp extends SilentHeartbeatTick {
|
|
82
89
|
agentName: string;
|
|
83
90
|
/** The separator text the tick's request rendered, which later compiles
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** A SHA-256 of `value` that doesn't depend on its objects' key order. */
|
|
4
|
+
export function stableHash(value: unknown): string {
|
|
5
|
+
const encode = (item: unknown): string => {
|
|
6
|
+
if (Array.isArray(item)) return `[${item.map(encode).join(',')}]`;
|
|
7
|
+
if (item && typeof item === 'object') {
|
|
8
|
+
const record = item as Record<string, unknown>;
|
|
9
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(',')}}`;
|
|
10
|
+
}
|
|
11
|
+
return JSON.stringify(item) ?? 'null';
|
|
12
|
+
};
|
|
13
|
+
return createHash('sha256').update(encode(value)).digest('hex');
|
|
14
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context injections kept where they were placed (context-manager #155,
|
|
3
|
+
* part 5).
|
|
4
|
+
*
|
|
5
|
+
* `compile()` places an `afterUser` injection after the last user message
|
|
6
|
+
* each time it runs, and nothing stores it. So at the next start, a new turn
|
|
7
|
+
* or a restart after a tool result, it's gone from where the agent's replies
|
|
8
|
+
* saw it, and on a model that binds a reply's thinking to everything before
|
|
9
|
+
* it, every reply after the old place loses its thinking. A moved injection
|
|
10
|
+
* also rewrites the previous turn's cached prefix. So at each start that
|
|
11
|
+
* sends, the framework appends each `afterUser` injection to the agent's own
|
|
12
|
+
* window, as the message `compile()` would have placed, unless the copy the
|
|
13
|
+
* agent will read as current (the one its last request held, or a newer one
|
|
14
|
+
* a start stored whose compile then didn't record) has the same content,
|
|
15
|
+
* every part of it, and the compile
|
|
16
|
+
* then renders
|
|
17
|
+
* it from the store like any message, where it stays. While the window is
|
|
18
|
+
* held (AgentFramework's slotHeldFrom), `compile()` places it instead. Being a
|
|
19
|
+
* message, it's history to the strategy: capped by its per-message bound,
|
|
20
|
+
* sharded, selected by the budget and folded like any other.
|
|
21
|
+
*
|
|
22
|
+
* A stored injection's row carries `system: true`, `kind:
|
|
23
|
+
* 'context-injection'` and `contextInjection` in its metadata (the mark),
|
|
24
|
+
* naming the agent whose context it is and its source's namespace, so a
|
|
25
|
+
* reader can tell it from conversation, and so another agent reading the
|
|
26
|
+
* same slot leaves it out (AgentFramework's view filters). All three are
|
|
27
|
+
* keys only the host writes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ContextInjection } from '@animalabs/context-manager';
|
|
31
|
+
import { stableHash } from './stable-hash.js';
|
|
32
|
+
import { injectionsAsStored } from './text-as-stored.js';
|
|
33
|
+
|
|
34
|
+
/** The metadata key a stored injection's row is marked with. */
|
|
35
|
+
export const STORED_INJECTION_KEY = 'contextInjection';
|
|
36
|
+
|
|
37
|
+
/** The `kind` a stored injection's row carries beside `system: true`, as the
|
|
38
|
+
* framework's other rows of its own do (a clock row, a notice). */
|
|
39
|
+
export const STORED_INJECTION_KIND = 'context-injection';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The mark: whose context the row is, its source's namespace, a hash of the
|
|
43
|
+
* content its source gave at that start (the unit), in its stored form
|
|
44
|
+
* (unitHash), and which of the unit's `parts` injections the row holds
|
|
45
|
+
* (`part`, from 0). Every row of one injection, a sharded one's too, carries
|
|
46
|
+
* the same `part`.
|
|
47
|
+
*/
|
|
48
|
+
export interface StoredInjectionMark {
|
|
49
|
+
agent: string;
|
|
50
|
+
namespace: string;
|
|
51
|
+
contentHash: string;
|
|
52
|
+
part: number;
|
|
53
|
+
parts: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The participant `compile()` gives an injection it places, which a stored
|
|
57
|
+
* one keeps, so it renders as the placed one did. */
|
|
58
|
+
export function injectionParticipant(namespace: string): string {
|
|
59
|
+
return `system_context:${namespace}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A stored injection's mark, read from a row's metadata, so a host's reader
|
|
64
|
+
* tells a stored injection from conversation by the rule the framework's own
|
|
65
|
+
* views use. Undefined for a row with no mark, and for one whose mark isn't
|
|
66
|
+
* whole (a string `agent`, `namespace` and `contentHash`, and integer
|
|
67
|
+
* `part` and `parts` with 0 <= part < parts): no view of the framework's
|
|
68
|
+
* treats such a row as a stored injection, so neither should a host's.
|
|
69
|
+
*/
|
|
70
|
+
export function storedInjectionMarkOf(metadata: unknown): StoredInjectionMark | undefined {
|
|
71
|
+
const mark = (metadata as Record<string, unknown> | undefined)?.[STORED_INJECTION_KEY];
|
|
72
|
+
if (mark === null || typeof mark !== 'object') return undefined;
|
|
73
|
+
const { agent, namespace, contentHash, part, parts } = mark as Record<string, unknown>;
|
|
74
|
+
return typeof agent === 'string' && typeof namespace === 'string' && typeof contentHash === 'string'
|
|
75
|
+
&& Number.isInteger(part) && Number.isInteger(parts) && (part as number) >= 0 && (part as number) < (parts as number)
|
|
76
|
+
? { agent, namespace, contentHash, part: part as number, parts: parts as number }
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a row is another agent's stored injection, which `agentName`'s
|
|
81
|
+
* view leaves out: it's that agent's context, not conversation. */
|
|
82
|
+
export function isAnotherAgentsInjection(metadata: unknown, agentName: string): boolean {
|
|
83
|
+
const mark = storedInjectionMarkOf(metadata);
|
|
84
|
+
return mark !== undefined && mark.agent !== agentName;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether an injection is stored, rather than placed by `compile()` each
|
|
89
|
+
* time: an `afterUser` one, unless it opts out
|
|
90
|
+
* (ModuleContextInjection.placedEachCompile). Only a module's can: an MCPL
|
|
91
|
+
* server's is built from four fields alone (convertMcplInjection), so it
|
|
92
|
+
* never carries the field.
|
|
93
|
+
*/
|
|
94
|
+
export function isStoredKind(injection: ContextInjection): boolean {
|
|
95
|
+
return injection.position === 'afterUser'
|
|
96
|
+
&& (injection as { placedEachCompile?: unknown }).placedEachCompile !== true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether content leaves anything in a request: a block that isn't a text
|
|
101
|
+
* block, or a text block with non-blank text, as the request's sanitizer
|
|
102
|
+
* keeps (Agent's assembleActivationRequest drops blank text blocks, and a
|
|
103
|
+
* message left with none). An injection with none isn't stored: its row
|
|
104
|
+
* would vanish from every request, and the count of stored rows the
|
|
105
|
+
* request ends on, which keeps [Continue] last, would then be wrong.
|
|
106
|
+
* compile() places it, and the sanitizer drops it, as before.
|
|
107
|
+
*/
|
|
108
|
+
export function hasRenderedContent(content: ContextInjection['content']): boolean {
|
|
109
|
+
return Array.isArray(content) && content.some((block) =>
|
|
110
|
+
!((block as { type?: unknown }).type === 'text'
|
|
111
|
+
&& (typeof (block as { text?: unknown }).text !== 'string' || ((block as { text: string }).text).trim() === '')));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One injection as its row would store it: its namespace as text (as
|
|
115
|
+
* compile() writes it into the participant's name, so one that isn't a
|
|
116
|
+
* string, an MCPL server's number or none, is read so), that participant,
|
|
117
|
+
* and its content in the stored form (injectionsAsStored), as the compile
|
|
118
|
+
* would have taken it. The store's write would mend the content the same
|
|
119
|
+
* way; the namespace, which the mark carries too, it wouldn't. */
|
|
120
|
+
export function asStoredRow(injection: ContextInjection): { namespace: string; participant: string; content: ContextInjection['content'] } {
|
|
121
|
+
const [stored] = injectionsAsStored([injection])!;
|
|
122
|
+
const namespace = `${stored!.namespace}`;
|
|
123
|
+
return { namespace, participant: injectionParticipant(namespace), content: stored!.content };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The hash of a unit's content in its stored form, which the same-content
|
|
127
|
+
* skip compares: the mark's, from the gather that stored the unit, with
|
|
128
|
+
* this gather's. */
|
|
129
|
+
export function unitHash(rows: ReadonlyArray<{ content: ContextInjection['content'] }>): string {
|
|
130
|
+
return stableHash(rows.map((r) => r.content));
|
|
131
|
+
}
|
package/src/text-as-stored.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* request compiles comes from the store, which writes a lone UTF-16
|
|
5
5
|
* surrogate as U+FFFD, and a provider refuses a request carrying one (#695).
|
|
6
6
|
* Two things reach a request without the store, and each is made well formed
|
|
7
|
-
* where it leaves for it: context injections
|
|
8
|
-
*
|
|
9
|
-
* they go to
|
|
7
|
+
* where it leaves for it: context injections compile() places (those not
|
|
8
|
+
* stored, stored-injections.ts), as an Agent compiles them into a request,
|
|
9
|
+
* and the copies a tool boundary injects into the live round, as they go to
|
|
10
|
+
* the stream. A stored injection takes the same form before it's stored.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ContentBlock, NormalizedMessage, ThinkingBindingEntry } from '@animalabs/membrane';
|
|
2
|
+
import type { CompileProvenance, MessageId } from '@animalabs/context-manager';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a block the provider dropped (membrane's `RoundReport.thinking.dropped`) came from.
|
|
6
|
+
*
|
|
7
|
+
* - `reply`: a block of a stored reply the compile rendered. `bodies` are the stored bodies that compiled message
|
|
8
|
+
* was rendered from (`CompileProvenance`'s raw bodies), each by the id `addMessage` returned for it and its
|
|
9
|
+
* sequence (a body kept in shards is one, by its first shard): usually one. Where the block's signature is in
|
|
10
|
+
* more than one compiled message, it's every one of theirs, since the block alone can't tell them apart.
|
|
11
|
+
* - `round`: a block of a round this stream appended itself (the entry's `round`), which no compile holds.
|
|
12
|
+
* - `unplaced`: membrane couldn't place it, or it names a block this request didn't send from a stored reply:
|
|
13
|
+
* an injected message's, one whose compiled message has no raw body (a summary), or one whose signature no
|
|
14
|
+
* compiled block carries.
|
|
15
|
+
*/
|
|
16
|
+
export type PlacedDrop =
|
|
17
|
+
| { kind: 'reply'; bodies: Array<{ messageId: MessageId; sequence: number }>; entry: ThinkingBindingEntry }
|
|
18
|
+
| { kind: 'round'; round: number; entry: ThinkingBindingEntry }
|
|
19
|
+
| { kind: 'unplaced'; entry: ThinkingBindingEntry };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a request was assembled from: the compile's messages as it returned them, its provenance, and, when the
|
|
23
|
+
* compile gave one, the sequence at or below which a reply was stored before the branch's thinking-binding record
|
|
24
|
+
* began (context-manager's `CompileResult.thinkingRecordedAfter`, where it keeps a thinking-binding record).
|
|
25
|
+
*/
|
|
26
|
+
export interface CompiledForRequest {
|
|
27
|
+
messages: readonly NormalizedMessage[];
|
|
28
|
+
provenance?: CompileProvenance;
|
|
29
|
+
recordedAfter?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What a stream's driver reads a call's dropped blocks against (`StartStreamResult`'s two fields, together). */
|
|
33
|
+
export interface StreamThinkingDrops {
|
|
34
|
+
place: (entries: readonly ThinkingBindingEntry[]) => PlacedDrop[];
|
|
35
|
+
recordedAfter?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A signed block's identity on the wire: a thinking block's signature, a redacted block's data. */
|
|
39
|
+
function bindingKey(block: ContentBlock | undefined): string | undefined {
|
|
40
|
+
const b = block as { type?: unknown; signature?: unknown; data?: unknown } | undefined;
|
|
41
|
+
if (b?.type === 'thinking' && typeof b.signature === 'string' && b.signature.length > 0) return `s:${b.signature}`;
|
|
42
|
+
if (b?.type === 'redacted_thinking' && typeof b.data === 'string' && b.data.length > 0) return `d:${b.data}`;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Place each dropped entry of one call. membrane places a block by the request's message and the block's
|
|
48
|
+
* index in it; the request holds copies of the compile's messages (the sanitize pass, `withoutTokenEstimates`,
|
|
49
|
+
* the request-only rows and `prepareRequest` each copy or move them), so the block is found again in the
|
|
50
|
+
* compile by its signature (or a redacted block's data), which every copy keeps, and the compiled message's
|
|
51
|
+
* provenance names the stored reply.
|
|
52
|
+
*/
|
|
53
|
+
export function placeThinkingDrops(
|
|
54
|
+
entries: readonly ThinkingBindingEntry[],
|
|
55
|
+
sent: readonly NormalizedMessage[],
|
|
56
|
+
compiled: CompiledForRequest,
|
|
57
|
+
): PlacedDrop[] {
|
|
58
|
+
let byKey: Map<string, number[]> | undefined;
|
|
59
|
+
const compiledIndexes = (key: string): number[] => {
|
|
60
|
+
if (!byKey) {
|
|
61
|
+
byKey = new Map();
|
|
62
|
+
compiled.messages.forEach((message, i) => {
|
|
63
|
+
for (const block of message.content) {
|
|
64
|
+
const k = bindingKey(block);
|
|
65
|
+
if (k === undefined) continue;
|
|
66
|
+
const at = byKey!.get(k);
|
|
67
|
+
if (!at) byKey!.set(k, [i]);
|
|
68
|
+
else if (at.at(-1) !== i) at.push(i);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return byKey.get(key) ?? [];
|
|
73
|
+
};
|
|
74
|
+
return entries.map((entry): PlacedDrop => {
|
|
75
|
+
if (entry.round !== undefined) return { kind: 'round', round: entry.round, entry };
|
|
76
|
+
if (entry.message === undefined || entry.block === undefined) return { kind: 'unplaced', entry };
|
|
77
|
+
const key = bindingKey(sent[entry.message]?.content[entry.block]);
|
|
78
|
+
const bodies: Array<{ messageId: MessageId; sequence: number }> = [];
|
|
79
|
+
for (const at of key === undefined ? [] : compiledIndexes(key)) {
|
|
80
|
+
const source = compiled.provenance?.messages[at];
|
|
81
|
+
// A candidate with no stored body (a summary's) can't be placed, so neither can the block.
|
|
82
|
+
if (source?.kind !== 'raw' || source.bodies.length === 0) return { kind: 'unplaced', entry };
|
|
83
|
+
for (const body of source.bodies) bodies.push({ messageId: body.messageId, sequence: body.sequence });
|
|
84
|
+
}
|
|
85
|
+
if (bodies.length === 0) return { kind: 'unplaced', entry };
|
|
86
|
+
return { kind: 'reply', bodies, entry };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A call's placed drops against the branch's binding-record start (`thinkingRecordedAfter`). Expected: a block of a
|
|
92
|
+
* stored reply whose every body sits at or below it, minted before the context manager recorded what the branch
|
|
93
|
+
* sent, so the provider may drop its thinking with no strip having missed; and a drop for a binding the record
|
|
94
|
+
* doesn't track, the account's organization or end user (`organization_binding_mismatch`,
|
|
95
|
+
* `end_user_binding_mismatch`). Unexpected: everything else, which the record doesn't account for: a reply above
|
|
96
|
+
* the start, a block of the stream's own rounds, or one that couldn't be placed, for a conversation or model
|
|
97
|
+
* mismatch or a reason not known here.
|
|
98
|
+
*/
|
|
99
|
+
export function splitThinkingDrops(
|
|
100
|
+
placed: readonly PlacedDrop[],
|
|
101
|
+
recordedAfter: number,
|
|
102
|
+
): { expected: PlacedDrop[]; unexpected: PlacedDrop[] } {
|
|
103
|
+
const expected: PlacedDrop[] = [];
|
|
104
|
+
const unexpected: PlacedDrop[] = [];
|
|
105
|
+
for (const drop of placed) {
|
|
106
|
+
const before = drop.kind === 'reply' && drop.bodies.every((body) => body.sequence <= recordedAfter);
|
|
107
|
+
(before || UNTRACKED_REASONS.has(drop.entry.reason) ? expected : unexpected).push(drop);
|
|
108
|
+
}
|
|
109
|
+
return { expected, unexpected };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Binding failures the context manager's record doesn't track: the account's, not the conversation's. */
|
|
113
|
+
const UNTRACKED_REASONS = new Set(['organization_binding_mismatch', 'end_user_binding_mismatch']);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* When an agent's unexpected drops call for an operator alert: at the first call that shows any, and again only
|
|
117
|
+
* when a call shows more than the alert last counted. Each call of a stream, and each stream until a strip takes
|
|
118
|
+
* them out, drops the same blocks again, so an unchanged count isn't news. A call that shows no unexpected drop, its
|
|
119
|
+
* report empty, absent or naming only expected ones, ends the incident: the blocks it counted no longer drop. The next
|
|
120
|
+
* one alerts afresh. It's held in memory, so a restart mid-incident alerts once more, and an agent's removal ends its
|
|
121
|
+
* incident (`forget`).
|
|
122
|
+
*/
|
|
123
|
+
export class ThinkingLossIncidents {
|
|
124
|
+
private readonly alerted = new Map<string, number>();
|
|
125
|
+
|
|
126
|
+
/** A call's report of `unexpected` drops for `agent`: the count to alert with, or undefined for no alert. */
|
|
127
|
+
observe(agent: string, unexpected: number): number | undefined {
|
|
128
|
+
if (unexpected === 0) {
|
|
129
|
+
this.alerted.delete(agent);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (unexpected <= (this.alerted.get(agent) ?? 0)) return undefined;
|
|
133
|
+
this.alerted.set(agent, unexpected);
|
|
134
|
+
return unexpected;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The agent is gone: its incident, if one is open, ends with it. */
|
|
138
|
+
forget(agent: string): void {
|
|
139
|
+
this.alerted.delete(agent);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/tool-image-ledger.ts
CHANGED
|
@@ -327,10 +327,10 @@ export interface StoredCut {
|
|
|
327
327
|
* Where a tool result's text longer than `cap` is cut: by its stored copy
|
|
328
328
|
* (cutStoredToolResult), by history's own truncation (truncateForHistory),
|
|
329
329
|
* and by its live copy, for each text block beside an image
|
|
330
|
-
* (
|
|
331
|
-
* and the live copy cut alike. Under a readers' bound the stored
|
|
332
|
-
* can come earlier (cutStoredToolResultToFit). The text's length
|
|
333
|
-
* isn't longer than `cap`.
|
|
330
|
+
* (toMembraneToolResult's nativeContent, framework.ts), so at one cap the
|
|
331
|
+
* stored copy and the live copy cut alike. Under a readers' bound the stored
|
|
332
|
+
* copy's cut can come earlier (cutStoredToolResultToFit). The text's length
|
|
333
|
+
* when it isn't longer than `cap`.
|
|
334
334
|
*
|
|
335
335
|
* The cut is made at the cap, or before it when the cap would split a
|
|
336
336
|
* surrogate pair (safeSlice's rule), and that is where the moves back are
|
|
@@ -367,10 +367,11 @@ export function toolResultCutAt(text: string, cap: number): number {
|
|
|
367
367
|
*
|
|
368
368
|
* `shown` is what the live copy showed beside the stored text: null when the
|
|
369
369
|
* live copy was the text itself, as for every result that carried no inline
|
|
370
|
-
* image
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
370
|
+
* image, or whose links and markers came to more than the cap together, so
|
|
371
|
+
* goesNative said no.
|
|
372
|
+
* Then what the cut keeps is exactly what the resident saw, and nothing past
|
|
373
|
+
* it is kept (#403). A result whose live copy went to the model as native
|
|
374
|
+
* blocks (framework.ts) sent every image whatever the text's length, so its stored text is the only
|
|
374
375
|
* place save_recent_image's inventory finds them, and a slot dropped by the
|
|
375
376
|
* cut would be an image the resident saw that the index slides past (#104).
|
|
376
377
|
* The image slots past the cut that lie wholly within what the live copy
|
package/src/tool-presentation.ts
CHANGED
|
@@ -124,7 +124,9 @@ function documentProblems(doc: unknown, descriptionsOnly: boolean): string[] {
|
|
|
124
124
|
|
|
125
125
|
function parseDocument(raw: string, descriptionsOnly: boolean): PresentationDocument {
|
|
126
126
|
let doc: unknown;
|
|
127
|
-
|
|
127
|
+
// Some Windows editors save UTF-8 with a leading U+FEFF, which read() keeps
|
|
128
|
+
// and JSON.parse refuses; drop exactly one, as parseGateJson does (#758).
|
|
129
|
+
try { doc = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw); }
|
|
128
130
|
catch (error) { throw new PresentationFileError([`it isn't valid JSON: ${errorMessage(error)}`]); }
|
|
129
131
|
// An object with no keys carries no settings, so it's the empty document,
|
|
130
132
|
// as a missing resident file is: it's what an operator writes by hand to
|
package/src/tool-result-guard.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { ContextManager, MessageId, MessageMetadata } from '@animalabs/cont
|
|
|
4
4
|
import type { ContentBlock, NormalizedMessage, ToolResult } from '@animalabs/membrane';
|
|
5
5
|
import type { CompletedToolCall } from './types/index.js';
|
|
6
6
|
import { ensureStateRegistered } from './module-registry.js';
|
|
7
|
-
import {
|
|
7
|
+
import { classifyBlock } from './mcpl/references.js';
|
|
8
|
+
import { resultClaimsOf, rfc005TextOf, toolResultDataToHistoryString } from './tool-result-history.js';
|
|
8
9
|
import { safeSlice } from './safe-slice.js';
|
|
9
10
|
|
|
10
11
|
export const TOOL_RESULT_GUARD_NOTICE = 'Tool result withheld by the guard. The tool has already executed.';
|
|
@@ -90,7 +91,9 @@ export function withheldResultNotice(spill: WithheldSpill, auditDurable: boolean
|
|
|
90
91
|
* inline cap or less (storeResults' keptCap), they're cut, with a note that
|
|
91
92
|
* says so and where the whole result is, since a later field can then be cut
|
|
92
93
|
* away entirely. Undefined when it keeps nothing, as for a failed result
|
|
93
|
-
* (agent-framework #277)
|
|
94
|
+
* (agent-framework #277), one whose claims can't be read whole
|
|
95
|
+
* (resultClaimsOf), or one whose data is MCP's embedded resource, whose
|
|
96
|
+
* fields can't be kept apart from its contents or its uri. A write's file
|
|
94
97
|
* list is evidence of what it did, rarely what drew a refusal, and keeping it
|
|
95
98
|
* is what shows a clobbered file when the receipt is withheld.
|
|
96
99
|
*/
|
|
@@ -99,13 +102,23 @@ export function keptWhenWithheld(result: CompletedToolCall['result'], maxChars:
|
|
|
99
102
|
if (!result.success || result.isError || !keys?.length || !data || typeof data !== 'object' || Array.isArray(data)) {
|
|
100
103
|
return undefined;
|
|
101
104
|
}
|
|
105
|
+
// MCP's embedded resource is its contents: shown as text, it's the very
|
|
106
|
+
// body the guard withheld, and a field picked out of it, such as
|
|
107
|
+
// `resource`, is data that shows its uri. So it keeps nothing.
|
|
108
|
+
if (classifyBlock(data).kind === 'embedded') return undefined;
|
|
102
109
|
const fields = data as Record<string, unknown>;
|
|
103
110
|
const kept = Object.fromEntries(keys.filter((key) => Object.hasOwn(fields, key)).map((key) => [key, fields[key]]));
|
|
104
111
|
if (Object.keys(kept).length === 0) return undefined;
|
|
105
112
|
// Data that RFC-005 shows as text, such as a reference's stub, keeps that
|
|
106
113
|
// text: picked out of it, a kept uri would lose the type that makes it a
|
|
107
114
|
// reference, and be written out of the stub that hides it.
|
|
108
|
-
|
|
115
|
+
// The kept fields are shown as the whole result claims (resultClaimsOf), so
|
|
116
|
+
// a never elsewhere in it still withholds a kept link's uri. Where a value
|
|
117
|
+
// stops that walk, such as a getter that throws, which the audit's JSON
|
|
118
|
+
// doesn't read, what the rest claims is unknown, and nothing is kept.
|
|
119
|
+
const claims = resultClaimsOf(fields);
|
|
120
|
+
if (claims === null) return undefined;
|
|
121
|
+
const whole = rfc005TextOf(fields) ?? toolResultDataToHistoryString(kept, undefined, { claims });
|
|
109
122
|
if (!maxChars || whole.length <= maxChars) return whole;
|
|
110
123
|
// Never half a surrogate pair: the stub goes to the provider as text, so
|
|
111
124
|
// the cut can be one character short of the cap.
|
|
@@ -500,7 +513,7 @@ export class ToolResultGuard {
|
|
|
500
513
|
/**
|
|
501
514
|
* Turn a withheld batch's notices into stubs that say where each original
|
|
502
515
|
* is (agent-framework #277): written to the workspace when it has a
|
|
503
|
-
* writable mount without an include to take it, else kept in the audit
|
|
516
|
+
* writable mount without an include, not configured direct, to take it, else kept in the audit
|
|
504
517
|
* record. A stub also keeps the file
|
|
505
518
|
* lists its tool marked. Its own unedited placeholder is
|
|
506
519
|
* the only thing it edits: an operator's later edit, removal or rollback
|
|
@@ -599,8 +612,8 @@ export class ToolResultGuard {
|
|
|
599
612
|
if (block.type !== 'tool_result') return [];
|
|
600
613
|
if (thrown.has(index)) return [{ toolUseId: block.toolUseId, error: thrown.get(index)! }];
|
|
601
614
|
const spill = spills.get(index) ?? null;
|
|
602
|
-
// No file was written: no writable mount without an include
|
|
603
|
-
// or the framework was stopping.
|
|
615
|
+
// No file was written: no writable mount without an include
|
|
616
|
+
// that isn't configured direct, or the framework was stopping.
|
|
604
617
|
if (spill === null) return [{ toolUseId: block.toolUseId, skipped: 'no_workspace_file' }];
|
|
605
618
|
if ('skipped' in spill) return [{ toolUseId: block.toolUseId, skipped: spill.skipped }];
|
|
606
619
|
return [{ toolUseId: block.toolUseId, path: spill.path,
|