@loomwork/middleware 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +82 -2
- package/dist/claimCheck.d.ts +179 -0
- package/dist/claimCheck.d.ts.map +1 -0
- package/dist/claimCheck.js +241 -0
- package/dist/claimCheck.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/killSwitchRegistry.d.ts +74 -0
- package/dist/killSwitchRegistry.d.ts.map +1 -0
- package/dist/killSwitchRegistry.js +108 -0
- package/dist/killSwitchRegistry.js.map +1 -0
- package/dist/metering.d.ts +81 -0
- package/dist/metering.d.ts.map +1 -0
- package/dist/metering.js +153 -0
- package/dist/metering.js.map +1 -0
- package/dist/nestedBudget.d.ts +60 -0
- package/dist/nestedBudget.d.ts.map +1 -0
- package/dist/nestedBudget.js +134 -0
- package/dist/nestedBudget.js.map +1 -0
- package/dist/profile.d.ts +38 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +70 -0
- package/dist/profile.js.map +1 -0
- package/dist/rateLimit.d.ts +40 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +83 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/redaction.d.ts +66 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +141 -0
- package/dist/redaction.js.map +1 -0
- package/dist/retry.d.ts +50 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +98 -0
- package/dist/retry.js.map +1 -0
- package/dist/safety.d.ts +93 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +152 -0
- package/dist/safety.js.map +1 -0
- package/dist/streamTokenBudget.d.ts +58 -0
- package/dist/streamTokenBudget.d.ts.map +1 -0
- package/dist/streamTokenBudget.js +47 -0
- package/dist/streamTokenBudget.js.map +1 -0
- package/package.json +55 -3
- package/src/claimCheck.ts +410 -0
- package/src/index.ts +79 -0
- package/src/killSwitchRegistry.ts +168 -0
- package/src/metering.ts +226 -0
- package/src/nestedBudget.ts +200 -0
- package/src/profile.ts +86 -0
- package/src/rateLimit.ts +126 -0
- package/src/redaction.ts +184 -0
- package/src/retry.ts +133 -0
- package/src/safety.ts +235 -0
- package/src/streamTokenBudget.ts +98 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { EffectContext } from "@loomwork/effect-runtime";
|
|
2
|
+
import type { Budget } from "./metering";
|
|
3
|
+
/**
|
|
4
|
+
* Estimate output tokens from generated text. The default is the usual ~4
|
|
5
|
+
* characters-per-token heuristic — deliberately crude and deliberately visible.
|
|
6
|
+
* Pass a real tokenizer when the budget is tight enough for the difference to
|
|
7
|
+
* matter.
|
|
8
|
+
*/
|
|
9
|
+
export type TokenEstimator = (text: string) => number;
|
|
10
|
+
export declare const estimateTokensByChars: TokenEstimator;
|
|
11
|
+
export interface StreamTokenBudgetOptions {
|
|
12
|
+
/** Swap in a real tokenizer. Default {@link estimateTokensByChars}. */
|
|
13
|
+
estimate?: TokenEstimator;
|
|
14
|
+
/**
|
|
15
|
+
* Check at most every N deltas.
|
|
16
|
+
*
|
|
17
|
+
* The DEFAULT estimator is O(1) — `text.length / 4`, and `.length` is a field
|
|
18
|
+
* read — so with it this knob buys almost nothing. It exists for a real
|
|
19
|
+
* tokenizer, which scans the text and is therefore O(text) per call: at every
|
|
20
|
+
* delta that would be quadratic over a generation, and every 8 still divides
|
|
21
|
+
* the same quadratic by 8 rather than removing it. If you pass a scanning
|
|
22
|
+
* estimator, prefer one that can score an increment.
|
|
23
|
+
*
|
|
24
|
+
* (An earlier version of this comment credited `checkEvery` with bounding a
|
|
25
|
+
* cost that lived in `collectStream`, not here — the caller rebuilt the whole
|
|
26
|
+
* text per delta before this guard was ever consulted, so the knob could not
|
|
27
|
+
* have helped. That is fixed; the note stays because the reasoning it corrects
|
|
28
|
+
* is easy to repeat.)
|
|
29
|
+
*/
|
|
30
|
+
checkEvery?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the `collectStream` guard that enforces a token {@link Budget} mid-flight.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* createLlmEffect({
|
|
37
|
+
* provider,
|
|
38
|
+
* stream: true,
|
|
39
|
+
* buildRequest,
|
|
40
|
+
* guard: streamTokenBudget({ maxTokens: 50_000 }),
|
|
41
|
+
* });
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* On breach it emits `QUOTA_EXCEEDED` — the same durable fact
|
|
45
|
+
* `meteringMiddleware` writes, so an over-budget run looks the same to analytics
|
|
46
|
+
* whichever gate caught it — and throws `QuotaExceededError`, which propagates
|
|
47
|
+
* out of the `for await` and releases the HTTP call through K8.1's abort path.
|
|
48
|
+
*
|
|
49
|
+
* What is already spent comes from `ctx.resources` (K3's running totals from the
|
|
50
|
+
* durable fold), so the ceiling covers the execution rather than this one call.
|
|
51
|
+
* With no `ctx.resources` wired, only this generation's own output counts —
|
|
52
|
+
* degraded, but never wrong in the dangerous direction.
|
|
53
|
+
*/
|
|
54
|
+
export declare function streamTokenBudget(budget: Budget, options?: StreamTokenBudgetOptions): (ctx: EffectContext) => (progress: {
|
|
55
|
+
text: string;
|
|
56
|
+
deltas: number;
|
|
57
|
+
}) => void;
|
|
58
|
+
//# sourceMappingURL=streamTokenBudget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"streamTokenBudget.d.ts","sourceRoot":"","sources":["../src/streamTokenBudget.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAuBzC;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;AAEtD,eAAO,MAAM,qBAAqB,EAAE,cAAqD,CAAC;AAE1F,MAAM,WAAW,wBAAwB;IACvC,uEAAuE;IACvE,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;;;;;;;;;;;;;OAeG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,wBAA6B,GACrC,CAAC,GAAG,EAAE,aAAa,KAAK,CAAC,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAgB9E"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.estimateTokensByChars = void 0;
|
|
4
|
+
exports.streamTokenBudget = streamTokenBudget;
|
|
5
|
+
const metering_1 = require("./metering");
|
|
6
|
+
const estimateTokensByChars = (text) => Math.ceil(text.length / 4);
|
|
7
|
+
exports.estimateTokensByChars = estimateTokensByChars;
|
|
8
|
+
/**
|
|
9
|
+
* Build the `collectStream` guard that enforces a token {@link Budget} mid-flight.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* createLlmEffect({
|
|
13
|
+
* provider,
|
|
14
|
+
* stream: true,
|
|
15
|
+
* buildRequest,
|
|
16
|
+
* guard: streamTokenBudget({ maxTokens: 50_000 }),
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* On breach it emits `QUOTA_EXCEEDED` — the same durable fact
|
|
21
|
+
* `meteringMiddleware` writes, so an over-budget run looks the same to analytics
|
|
22
|
+
* whichever gate caught it — and throws `QuotaExceededError`, which propagates
|
|
23
|
+
* out of the `for await` and releases the HTTP call through K8.1's abort path.
|
|
24
|
+
*
|
|
25
|
+
* What is already spent comes from `ctx.resources` (K3's running totals from the
|
|
26
|
+
* durable fold), so the ceiling covers the execution rather than this one call.
|
|
27
|
+
* With no `ctx.resources` wired, only this generation's own output counts —
|
|
28
|
+
* degraded, but never wrong in the dangerous direction.
|
|
29
|
+
*/
|
|
30
|
+
function streamTokenBudget(budget, options = {}) {
|
|
31
|
+
const estimate = options.estimate ?? exports.estimateTokensByChars;
|
|
32
|
+
const checkEvery = Math.max(1, options.checkEvery ?? 8);
|
|
33
|
+
return (ctx) => (progress) => {
|
|
34
|
+
if (budget.maxTokens === undefined)
|
|
35
|
+
return;
|
|
36
|
+
if (progress.deltas % checkEvery !== 0)
|
|
37
|
+
return;
|
|
38
|
+
const alreadySpent = ctx.resources?.tokens ?? 0;
|
|
39
|
+
const projected = alreadySpent + estimate(progress.text);
|
|
40
|
+
if (projected < budget.maxTokens)
|
|
41
|
+
return;
|
|
42
|
+
const reason = `maxTokens ${budget.maxTokens} exceeded mid-stream (~${projected} estimated, ${alreadySpent} already metered)`;
|
|
43
|
+
ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason });
|
|
44
|
+
throw new metering_1.QuotaExceededError(reason);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=streamTokenBudget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"streamTokenBudget.js","sourceRoot":"","sources":["../src/streamTokenBudget.ts"],"names":[],"mappings":";;;AA8EA,8CAmBC;AA/FD,yCAAgD;AA8BzC,MAAM,qBAAqB,GAAmB,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AAA7E,QAAA,qBAAqB,yBAAwD;AAwB1F;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,SAAgB,iBAAiB,CAC/B,MAAc,EACd,UAAoC,EAAE;IAEtC,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,6BAAqB,CAAC;IAC3D,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC;IAExD,OAAO,CAAC,GAAkB,EAAE,EAAE,CAAC,CAAC,QAAQ,EAAE,EAAE;QAC1C,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;YAAE,OAAO;QAC3C,IAAI,QAAQ,CAAC,MAAM,GAAG,UAAU,KAAK,CAAC;YAAE,OAAO;QAE/C,MAAM,YAAY,GAAG,GAAG,CAAC,SAAS,EAAE,MAAM,IAAI,CAAC,CAAC;QAChD,MAAM,SAAS,GAAG,YAAY,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,SAAS,GAAG,MAAM,CAAC,SAAS;YAAE,OAAO;QAEzC,MAAM,MAAM,GAAG,aAAa,MAAM,CAAC,SAAS,0BAA0B,SAAS,eAAe,YAAY,mBAAmB,CAAC;QAC9H,GAAG,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3D,MAAM,IAAI,6BAAkB,CAAC,MAAM,CAAC,CAAC;IACvC,CAAC,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomwork/middleware",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Reference effect middleware for Loom — cross-cutting concerns (retries, and later logging/idempotency/tracing) around effect execution, replay-safe and statically ordered",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"require": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@loomwork/classify": "^0.1.1",
|
|
16
|
+
"@loomwork/core": "^0.1.1",
|
|
17
|
+
"@loomwork/effect-runtime": "^0.1.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"typescript": "^5.6.3",
|
|
22
|
+
"vitest": "^2.1.8",
|
|
23
|
+
"zod": "^3.23.8"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/chhetrisushil/loom.git",
|
|
29
|
+
"directory": "packages/middleware"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts",
|
|
35
|
+
"!src/**/*.test.tsx",
|
|
36
|
+
"!src/**/__tests__",
|
|
37
|
+
"!src/**/__fixtures__",
|
|
38
|
+
"!src/**/__snapshots__",
|
|
39
|
+
"!src/**/__golden__",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"NOTICE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"tag": "next"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20.0.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc --build",
|
|
52
|
+
"dev": "tsc --build --watch",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
56
|
+
"typecheck:tests": "tsc -p tsconfig.test.json"
|
|
57
|
+
}
|
|
6
58
|
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { nestedTaintOrigin, tainted } from "@loomwork/classify";
|
|
2
|
+
import type { EffectMiddleware } from "@loomwork/effect-runtime";
|
|
3
|
+
|
|
4
|
+
// ── ADR 0051 D3 / ADR 0054 — claim-check: the reference IS the recorded value ─
|
|
5
|
+
//
|
|
6
|
+
// Effect results over an inline cap are stored in a content-addressed store and
|
|
7
|
+
// the log records `{ ref, size, digest }` AS the step output. Deliberately NOT
|
|
8
|
+
// ADR 0046 externalization: there is no resolving decorator on this path — the
|
|
9
|
+
// fold never touches the content store, replay never resolves, and erasing an
|
|
10
|
+
// unpinned blob is invisible to replay. Orchestration logic may branch only on
|
|
11
|
+
// the recorded envelope (in practice: the digest — the tool's declared
|
|
12
|
+
// deterministic projection). Reaching through the ref from flow control
|
|
13
|
+
// reintroduces nondeterminism and is lintable (CC-5, tools/*).
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ADR 0143 - the execution a content operation is for (structural twin of `@loomwork/plugin-content`'s
|
|
17
|
+
* `ContentAccess`, ContentStore SPI 1.5.0). The middleware passes the running effect's execution on every
|
|
18
|
+
* `put`, so a store that seals per subject knows whose key to use; a plain store ignores it.
|
|
19
|
+
*/
|
|
20
|
+
export interface ClaimCheckAccess {
|
|
21
|
+
readonly executionId: string;
|
|
22
|
+
readonly workflowId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Write-side view of the content store (structural — no plugin dependency). */
|
|
26
|
+
export interface ClaimCheckStore {
|
|
27
|
+
/** Content-addressed, idempotent by hash — the `ContentStore.put` contract (ADR 0046). */
|
|
28
|
+
put(bytes: Uint8Array, access?: ClaimCheckAccess): Promise<{ __loomContent: string }>;
|
|
29
|
+
/** OPTIONAL (ST-1, ADR 0059) — streaming ingestion; required only for stream-typed results. */
|
|
30
|
+
putStream?(
|
|
31
|
+
stream: AsyncIterable<Uint8Array>,
|
|
32
|
+
access?: ClaimCheckAccess
|
|
33
|
+
): Promise<{ __loomContent: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read-side view, for {@link resolveClaimCheck} OUTSIDE the determinism boundary. */
|
|
37
|
+
export interface ClaimCheckReader {
|
|
38
|
+
get(ref: { __loomContent: string }, access?: ClaimCheckAccess): Promise<Uint8Array | null>;
|
|
39
|
+
/**
|
|
40
|
+
* OPTIONAL (TS-10, ADR 0062) — batched get, positionally aligned, null per
|
|
41
|
+
* absent entry. A `ContentStore` with `getMany` satisfies this structurally.
|
|
42
|
+
*/
|
|
43
|
+
getMany?(
|
|
44
|
+
refs: readonly { __loomContent: string }[],
|
|
45
|
+
access?: ClaimCheckAccess
|
|
46
|
+
): Promise<(Uint8Array | null)[]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What STEP_COMPLETED records in place of an over-cap effect result. */
|
|
50
|
+
export interface ClaimCheckEnvelope {
|
|
51
|
+
readonly __loomClaimCheck: true;
|
|
52
|
+
readonly ref: { readonly __loomContent: string };
|
|
53
|
+
/** Byte length of the offloaded payload. */
|
|
54
|
+
readonly size: number;
|
|
55
|
+
/** The tool's declared deterministic projection of the result (CC-2), if any. */
|
|
56
|
+
readonly digest?: unknown;
|
|
57
|
+
/**
|
|
58
|
+
* How the offloaded bytes decode. Absent = JSON (the classic offload path);
|
|
59
|
+
* stream-typed results record their declared codec ("bytes" default) —
|
|
60
|
+
* resolve those with {@link resolveClaimCheckBytes}, not JSON parsing.
|
|
61
|
+
*/
|
|
62
|
+
readonly codec?: string;
|
|
63
|
+
/**
|
|
64
|
+
* ADR 0140 - `true`: the offloaded bytes are the ONLY copy of a value the log once held (the opt-in
|
|
65
|
+
* agent claim-check), so the content sweep treats the ref as LOAD-BEARING: kept while any live log,
|
|
66
|
+
* archive segment or snapshot cites it, freed only when that history is erased or discarded. Absent
|
|
67
|
+
* (the classic ADR 0054 telemetry offload): the ref is TTL-reclaimable (ADR 0055 D4).
|
|
68
|
+
*/
|
|
69
|
+
readonly retain?: true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── ST-2 (ADR 0059) — stream-typed tool results ───────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// A tool whose result is too large to hold in memory returns
|
|
75
|
+
// `streamResult(stream, { digest })` instead of the payload: the middleware
|
|
76
|
+
// streams it into the content store INSIDE the effect (transient side of the
|
|
77
|
+
// determinism boundary) and the recorded value is still the small envelope.
|
|
78
|
+
// The digest MUST come from the tool (computed while producing) — the bytes
|
|
79
|
+
// are consumed by the upload, so no post-hoc projection is possible.
|
|
80
|
+
|
|
81
|
+
export interface StreamedResult {
|
|
82
|
+
readonly __loomStreamResult: true;
|
|
83
|
+
readonly stream: AsyncIterable<Uint8Array>;
|
|
84
|
+
/** The declared deterministic projection (CC-2). Pure w.r.t. the streamed content. */
|
|
85
|
+
readonly digest?: unknown;
|
|
86
|
+
/** Recorded on the envelope; default "bytes". */
|
|
87
|
+
readonly codec?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Wrap a byte stream as a tool result; Node `Readable`s satisfy the iterable directly. */
|
|
91
|
+
export function streamResult(
|
|
92
|
+
stream: AsyncIterable<Uint8Array>,
|
|
93
|
+
opts: { digest?: unknown; codec?: string } = {}
|
|
94
|
+
): StreamedResult {
|
|
95
|
+
return {
|
|
96
|
+
__loomStreamResult: true,
|
|
97
|
+
stream,
|
|
98
|
+
...(opts.digest !== undefined && { digest: opts.digest }),
|
|
99
|
+
...(opts.codec !== undefined && { codec: opts.codec }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isStreamResult(value: unknown): value is StreamedResult {
|
|
104
|
+
return (
|
|
105
|
+
typeof value === "object" &&
|
|
106
|
+
value !== null &&
|
|
107
|
+
(value as { __loomStreamResult?: unknown }).__loomStreamResult === true &&
|
|
108
|
+
typeof (value as { stream?: unknown }).stream === "object"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isClaimCheck(value: unknown): value is ClaimCheckEnvelope {
|
|
113
|
+
return (
|
|
114
|
+
typeof value === "object" &&
|
|
115
|
+
value !== null &&
|
|
116
|
+
(value as { __loomClaimCheck?: unknown }).__loomClaimCheck === true &&
|
|
117
|
+
typeof (value as { ref?: { __loomContent?: unknown } }).ref?.__loomContent === "string"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** CC-3 — offload observability: what, where, how big. */
|
|
122
|
+
export interface ClaimCheckInfo {
|
|
123
|
+
toolId?: string;
|
|
124
|
+
stepId: string;
|
|
125
|
+
executionId: string;
|
|
126
|
+
workflowId: string;
|
|
127
|
+
size: number;
|
|
128
|
+
contentAddress: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ClaimCheckOptions {
|
|
132
|
+
store: ClaimCheckStore;
|
|
133
|
+
/**
|
|
134
|
+
* Inline cap in bytes for the JSON-encoded effect result; results at or under
|
|
135
|
+
* the cap are recorded inline, untouched. Default 16 KiB.
|
|
136
|
+
*/
|
|
137
|
+
inlineCapBytes?: number;
|
|
138
|
+
/**
|
|
139
|
+
* CC-2 — `toolId → digest` declarations. A digest is the tool's DETERMINISTIC
|
|
140
|
+
* projection of its result: the one part of an offloaded payload that
|
|
141
|
+
* orchestration may branch on, recorded inline in the envelope. MUST be a pure
|
|
142
|
+
* function of the result. Tools without a declared digest record `{ ref, size }`
|
|
143
|
+
* only — their orchestration can branch on nothing but existence.
|
|
144
|
+
*/
|
|
145
|
+
digests?: Readonly<Record<string, (result: unknown) => unknown>>;
|
|
146
|
+
/**
|
|
147
|
+
* The projection used for an offloaded result whose tool has NO entry in {@link digests}
|
|
148
|
+
* (a per-tool digest always wins). Same contract as a digest: PURE w.r.t. the result, because it is
|
|
149
|
+
* recorded inline in the envelope. Unset (the default) keeps the classic behaviour: such a tool
|
|
150
|
+
* records `{ ref, size }` only. {@link previewDigest} is a ready-made one.
|
|
151
|
+
*/
|
|
152
|
+
defaultDigest?: (result: unknown, toolId: string | undefined) => unknown;
|
|
153
|
+
/** CC-3 — called after each offload (metrics/log-budget regression tracking). */
|
|
154
|
+
onOffload?: (info: ClaimCheckInfo) => void;
|
|
155
|
+
/**
|
|
156
|
+
* ADR 0140 - mark the envelope `retain: true` (see {@link ClaimCheckEnvelope.retain}): the offloaded
|
|
157
|
+
* blob is audit evidence the content sweep must not TTL-reclaim. A function decides per tool id. Default
|
|
158
|
+
* unset: the classic reclaimable telemetry offload.
|
|
159
|
+
*/
|
|
160
|
+
retain?: boolean | ((toolId: string | undefined) => boolean);
|
|
161
|
+
/**
|
|
162
|
+
* ADR 0140 - results this returns `true` for pass through UNTOUCHED, never offloaded (e.g. a media
|
|
163
|
+
* result, whose parts the loop renders itself). Runs before the cap.
|
|
164
|
+
*/
|
|
165
|
+
passThrough?: (result: unknown, toolId: string | undefined) => boolean;
|
|
166
|
+
/**
|
|
167
|
+
* ADR 0140 - treat a result that already LOOKS like an envelope as DATA, not as a ref this middleware
|
|
168
|
+
* made: it is force-offloaded like any other value, so the log never records a tool-forged
|
|
169
|
+
* `{ __loomClaimCheck, ref }` the sweep, the resolvers or the model would trust. Default unset: an
|
|
170
|
+
* existing envelope passes through (stacked wiring, never double-wrapped).
|
|
171
|
+
*/
|
|
172
|
+
envelopesAreData?: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* CC-1 — claim-check middleware on the ADR 0006 seam. Runs only when the effect
|
|
177
|
+
* actually executes (replayed/completed steps short-circuit upstream), so replay
|
|
178
|
+
* determinism is automatic: the envelope is the recorded value. Crash-recovery
|
|
179
|
+
* re-runs re-offload the same bytes to the same content address (`put` is
|
|
180
|
+
* idempotent by hash), reproducing an identical envelope.
|
|
181
|
+
*
|
|
182
|
+
* Results that JSON cannot encode (circular, undefined) pass through untouched —
|
|
183
|
+
* offload is impossible for what the journal could not record anyway.
|
|
184
|
+
*/
|
|
185
|
+
export function claimCheckMiddleware(options: ClaimCheckOptions): EffectMiddleware {
|
|
186
|
+
const cap = options.inlineCapBytes ?? 16 * 1024;
|
|
187
|
+
const encoder = new TextEncoder();
|
|
188
|
+
return async (ctx, _input, next): Promise<unknown> => {
|
|
189
|
+
const result = await next();
|
|
190
|
+
const access: ClaimCheckAccess = { executionId: ctx.executionId, workflowId: ctx.workflowId };
|
|
191
|
+
if (options.passThrough?.(result, ctx.toolId) === true) return result;
|
|
192
|
+
// already an envelope (stacked wiring) — never double-wrap; unless envelopes are DATA (ADR 0140 M7)
|
|
193
|
+
const forged = options.envelopesAreData === true && isClaimCheck(result);
|
|
194
|
+
if (!forged && isClaimCheck(result)) return result;
|
|
195
|
+
const retain =
|
|
196
|
+
typeof options.retain === "function" ? options.retain(ctx.toolId) : options.retain === true;
|
|
197
|
+
|
|
198
|
+
// ST-2 — stream-typed result: ALWAYS offloads (a stream is a declared-big
|
|
199
|
+
// payload), never buffered by the middleware (a counting wrapper is the only
|
|
200
|
+
// thing between the tool and the store).
|
|
201
|
+
if (isStreamResult(result)) {
|
|
202
|
+
if (options.store.putStream === undefined) {
|
|
203
|
+
throw new TypeError(
|
|
204
|
+
`claim-check: tool "${ctx.toolId ?? ctx.stepId}" returned a streamed result but the content store does not implement putStream() (ST-1, ADR 0059)`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
let size = 0;
|
|
208
|
+
const source = result.stream;
|
|
209
|
+
const counted = (async function* () {
|
|
210
|
+
for await (const chunk of source) {
|
|
211
|
+
size += chunk.byteLength;
|
|
212
|
+
yield chunk;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
let streamedRef: { __loomContent: string };
|
|
216
|
+
try {
|
|
217
|
+
streamedRef = await options.store.putStream(counted, access);
|
|
218
|
+
} finally {
|
|
219
|
+
// W-audit leak fix: if putStream threw mid-consumption, `counted` is
|
|
220
|
+
// suspended at a yield and nobody would ever finalize it — resuming it
|
|
221
|
+
// with a return completion propagates through its for-await to the
|
|
222
|
+
// SOURCE iterator's return(), closing whatever the tool handed us
|
|
223
|
+
// (file handle, socket). A no-op when the stream was fully consumed.
|
|
224
|
+
await counted.return(undefined).then(
|
|
225
|
+
() => {},
|
|
226
|
+
() => {} // the offload is already failing; source cleanup errors don't mask it
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const envelope: ClaimCheckEnvelope = {
|
|
230
|
+
__loomClaimCheck: true,
|
|
231
|
+
ref: { __loomContent: streamedRef.__loomContent },
|
|
232
|
+
size,
|
|
233
|
+
codec: result.codec ?? "bytes",
|
|
234
|
+
...(result.digest !== undefined && { digest: result.digest }),
|
|
235
|
+
...(retain && { retain: true as const }),
|
|
236
|
+
};
|
|
237
|
+
options.onOffload?.({
|
|
238
|
+
...(ctx.toolId !== undefined && { toolId: ctx.toolId }),
|
|
239
|
+
stepId: ctx.stepId,
|
|
240
|
+
executionId: ctx.executionId,
|
|
241
|
+
workflowId: ctx.workflowId,
|
|
242
|
+
size,
|
|
243
|
+
contentAddress: streamedRef.__loomContent,
|
|
244
|
+
});
|
|
245
|
+
return envelope;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let json: string | undefined;
|
|
249
|
+
try {
|
|
250
|
+
json = JSON.stringify(result);
|
|
251
|
+
} catch {
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
if (json === undefined) return result;
|
|
255
|
+
const bytes = encoder.encode(json);
|
|
256
|
+
// ADR 0140 D11: under `envelopesAreData` a lookalike envelope NESTED anywhere in the result is data
|
|
257
|
+
// too: the whole result is force-offloaded, so the log never records a tool-authored
|
|
258
|
+
// `{ __loomClaimCheck, ref, retain }` the sweep or erasure could mistake for one this middleware wrote.
|
|
259
|
+
const nestedForged = options.envelopesAreData === true && json.includes('"__loomClaimCheck"');
|
|
260
|
+
if (!forged && !nestedForged && bytes.byteLength <= cap) return result;
|
|
261
|
+
|
|
262
|
+
const ref = await options.store.put(bytes, access);
|
|
263
|
+
const declared = ctx.toolId === undefined ? undefined : options.digests?.[ctx.toolId];
|
|
264
|
+
const projected =
|
|
265
|
+
declared !== undefined ? declared(result) : options.defaultDigest?.(result, ctx.toolId);
|
|
266
|
+
const envelope: ClaimCheckEnvelope = {
|
|
267
|
+
__loomClaimCheck: true,
|
|
268
|
+
ref: { __loomContent: ref.__loomContent },
|
|
269
|
+
size: bytes.byteLength,
|
|
270
|
+
...(projected !== undefined && { digest: projected }),
|
|
271
|
+
...(retain && { retain: true as const }),
|
|
272
|
+
};
|
|
273
|
+
options.onOffload?.({
|
|
274
|
+
...(ctx.toolId !== undefined && { toolId: ctx.toolId }),
|
|
275
|
+
stepId: ctx.stepId,
|
|
276
|
+
executionId: ctx.executionId,
|
|
277
|
+
workflowId: ctx.workflowId,
|
|
278
|
+
size: bytes.byteLength,
|
|
279
|
+
contentAddress: ref.__loomContent,
|
|
280
|
+
});
|
|
281
|
+
// ADR 0085 / 0140 - an offloaded result that carried untrusted content (a tainted top level OR a
|
|
282
|
+
// nested marker) must not launder it into a clean-looking envelope + digest: the envelope itself is
|
|
283
|
+
// tainted, so the loop fences it and records the taint (the sweep still finds the inner envelope).
|
|
284
|
+
let origin: string | undefined;
|
|
285
|
+
if (json.includes("__loomTainted")) {
|
|
286
|
+
// Scan the SERIALIZED form (what the log and the prompt hold). A marker that is only text still
|
|
287
|
+
// taints (fail closed).
|
|
288
|
+
try {
|
|
289
|
+
origin = nestedTaintOrigin(JSON.parse(json)) ?? "unknown:taint-marker-in-text";
|
|
290
|
+
} catch {
|
|
291
|
+
origin = "unknown:taint-marker-in-text";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return origin === undefined ? envelope : tainted(envelope, origin);
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What {@link previewDigest} records: the head of the result's JSON text. */
|
|
299
|
+
export interface PreviewDigest {
|
|
300
|
+
/** The first `maxChars` UTF-16 code units of `JSON.stringify(result)`, never ending mid-surrogate-pair. */
|
|
301
|
+
readonly preview: string;
|
|
302
|
+
/** `true` when the result's JSON continues past the preview (the tail is NOT shown); absent when the preview is the whole text. */
|
|
303
|
+
readonly truncated?: true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A ready-made `defaultDigest` (CC-2): the first `maxChars` (default 256) characters of the result's
|
|
308
|
+
* JSON text, so the model sees what an offloaded tool result BEGAN with instead of a bare hash. Pure
|
|
309
|
+
* and deterministic (a function of the result only). It is NOT a summary: it is a head, and it can cut
|
|
310
|
+
* a JSON string anywhere.
|
|
311
|
+
*
|
|
312
|
+
* Untrusted content is never previewed: a result carrying an ADR 0085 taint marker ANYWHERE (a nested
|
|
313
|
+
* `{ __loomTainted }` wrapper, such as an A2A reply's `content`) yields no digest at all, because a
|
|
314
|
+
* digest rides into the model's prompt as ordinary text and would launder the taint. A result JSON cannot
|
|
315
|
+
* encode yields none either.
|
|
316
|
+
*/
|
|
317
|
+
export function previewDigest(
|
|
318
|
+
maxChars = 256
|
|
319
|
+
): (result: unknown, toolId?: string) => PreviewDigest | undefined {
|
|
320
|
+
return (result) => {
|
|
321
|
+
let json: string | undefined;
|
|
322
|
+
try {
|
|
323
|
+
json = JSON.stringify(result);
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
if (json === undefined || json.includes('"__loomTainted"')) return undefined;
|
|
328
|
+
let end = Math.min(maxChars, json.length);
|
|
329
|
+
// Never end on a lone high surrogate (it would not round-trip through UTF-8).
|
|
330
|
+
if (end < json.length && end > 0) {
|
|
331
|
+
const last = json.charCodeAt(end - 1);
|
|
332
|
+
if (last >= 0xd800 && last <= 0xdbff) end--;
|
|
333
|
+
}
|
|
334
|
+
return { preview: json.slice(0, end), ...(end < json.length && { truncated: true as const }) };
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* CC-4 — resolve an envelope back to its payload, for use OUTSIDE the
|
|
340
|
+
* determinism boundary (rendering evidence in a UI, exporting a report).
|
|
341
|
+
* Returns `null` when the bytes were erased/never stored — the store's
|
|
342
|
+
* null-on-absence contract, surfaced as-is so callers decide loudness.
|
|
343
|
+
* Flow control must NOT branch on the resolved payload (CC-5 lint).
|
|
344
|
+
*/
|
|
345
|
+
export async function resolveClaimCheck<T = unknown>(
|
|
346
|
+
reader: ClaimCheckReader,
|
|
347
|
+
envelope: ClaimCheckEnvelope,
|
|
348
|
+
access?: ClaimCheckAccess
|
|
349
|
+
): Promise<T | null> {
|
|
350
|
+
if (envelope.codec !== undefined && envelope.codec !== "json") {
|
|
351
|
+
throw new TypeError(
|
|
352
|
+
`resolveClaimCheck: envelope codec is "${envelope.codec}", not JSON — use resolveClaimCheckBytes for stream-offloaded content`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const bytes = await reader.get(envelope.ref, access);
|
|
356
|
+
if (bytes === null) return null;
|
|
357
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as T;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* CC-4 for stream-offloaded content (ST-2): the raw bytes, `null` on absence.
|
|
362
|
+
* Same boundary rule as {@link resolveClaimCheck}: OUTSIDE the determinism
|
|
363
|
+
* boundary only.
|
|
364
|
+
*/
|
|
365
|
+
export async function resolveClaimCheckBytes(
|
|
366
|
+
reader: ClaimCheckReader,
|
|
367
|
+
envelope: ClaimCheckEnvelope,
|
|
368
|
+
access?: ClaimCheckAccess
|
|
369
|
+
): Promise<Uint8Array | null> {
|
|
370
|
+
return reader.get(envelope.ref, access);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* TS-10 (ADR 0062) — the batch twin of {@link resolveClaimCheck}, for
|
|
375
|
+
* interactive read paths (frozen-snapshot serving: a completed run's envelopes,
|
|
376
|
+
* one human waiting). One `getMany` round-trip when the reader supports it
|
|
377
|
+
* (coalesced parallel `get`s otherwise), deduped by content address; results
|
|
378
|
+
* positionally aligned with `envelopes`, `null` per erased/absent entry.
|
|
379
|
+
* Same boundary rule: OUTSIDE the determinism boundary only.
|
|
380
|
+
*/
|
|
381
|
+
export async function resolveClaimChecks<T = unknown>(
|
|
382
|
+
reader: ClaimCheckReader,
|
|
383
|
+
envelopes: readonly ClaimCheckEnvelope[],
|
|
384
|
+
access?: ClaimCheckAccess
|
|
385
|
+
): Promise<(T | null)[]> {
|
|
386
|
+
for (const envelope of envelopes) {
|
|
387
|
+
if (envelope.codec !== undefined && envelope.codec !== "json") {
|
|
388
|
+
throw new TypeError(
|
|
389
|
+
`resolveClaimChecks: envelope codec is "${envelope.codec}", not JSON — use resolveClaimCheckBytes for stream-offloaded content`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const distinct = new Map<string, { __loomContent: string }>();
|
|
394
|
+
for (const e of envelopes) {
|
|
395
|
+
if (!distinct.has(e.ref.__loomContent)) distinct.set(e.ref.__loomContent, e.ref);
|
|
396
|
+
}
|
|
397
|
+
const refs = [...distinct.values()];
|
|
398
|
+
const fetched =
|
|
399
|
+
reader.getMany !== undefined
|
|
400
|
+
? await reader.getMany(refs, access)
|
|
401
|
+
: await Promise.all(refs.map((r) => reader.get(r, access)));
|
|
402
|
+
const byAddress = new Map<string, Uint8Array | null>();
|
|
403
|
+
refs.forEach((r, i) => byAddress.set(r.__loomContent, fetched[i] ?? null));
|
|
404
|
+
const decoder = new TextDecoder();
|
|
405
|
+
return envelopes.map((e) => {
|
|
406
|
+
const bytes = byAddress.get(e.ref.__loomContent) ?? null;
|
|
407
|
+
// Decode per-occurrence so callers never alias one parsed object.
|
|
408
|
+
return bytes === null ? null : (JSON.parse(decoder.decode(bytes)) as T);
|
|
409
|
+
});
|
|
410
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export { effectTimingMiddleware } from "./profile";
|
|
2
|
+
export type { EffectProfile, EffectProfiler } from "./profile";
|
|
3
|
+
export { retryMiddleware, type RetryOptions } from "./retry";
|
|
4
|
+
export {
|
|
5
|
+
claimCheckMiddleware,
|
|
6
|
+
isClaimCheck,
|
|
7
|
+
isStreamResult,
|
|
8
|
+
previewDigest,
|
|
9
|
+
resolveClaimCheck,
|
|
10
|
+
resolveClaimCheckBytes,
|
|
11
|
+
resolveClaimChecks,
|
|
12
|
+
streamResult,
|
|
13
|
+
type ClaimCheckEnvelope,
|
|
14
|
+
type ClaimCheckInfo,
|
|
15
|
+
type ClaimCheckOptions,
|
|
16
|
+
type ClaimCheckAccess,
|
|
17
|
+
type ClaimCheckReader,
|
|
18
|
+
type ClaimCheckStore,
|
|
19
|
+
type PreviewDigest,
|
|
20
|
+
type StreamedResult,
|
|
21
|
+
} from "./claimCheck";
|
|
22
|
+
export {
|
|
23
|
+
meteringMiddleware,
|
|
24
|
+
llmUsageTokens,
|
|
25
|
+
QuotaExceededError,
|
|
26
|
+
type Budget,
|
|
27
|
+
type MeterInfo,
|
|
28
|
+
type MeteringOptions,
|
|
29
|
+
type Pricing,
|
|
30
|
+
type ResourceDelta,
|
|
31
|
+
} from "./metering";
|
|
32
|
+
export {
|
|
33
|
+
redactionMiddleware,
|
|
34
|
+
redactOutputFrom,
|
|
35
|
+
secretOutputPathsByStep,
|
|
36
|
+
secretRef,
|
|
37
|
+
isSecretRef,
|
|
38
|
+
InMemorySecretResolver,
|
|
39
|
+
type SecretRef,
|
|
40
|
+
type SecretResolver,
|
|
41
|
+
type RedactionOptions,
|
|
42
|
+
} from "./redaction";
|
|
43
|
+
export {
|
|
44
|
+
nestedBudgetMiddleware,
|
|
45
|
+
BudgetLedger,
|
|
46
|
+
type BudgetScope,
|
|
47
|
+
type NestedBudgetOptions,
|
|
48
|
+
type ScopeSpend,
|
|
49
|
+
} from "./nestedBudget";
|
|
50
|
+
export {
|
|
51
|
+
safetyMiddleware,
|
|
52
|
+
SafetyViolationError,
|
|
53
|
+
denyPatterns,
|
|
54
|
+
piiValidator,
|
|
55
|
+
schemaValidator,
|
|
56
|
+
killSwitch,
|
|
57
|
+
type SafetyPhase,
|
|
58
|
+
type SafetyCheckInfo,
|
|
59
|
+
type SafetyVerdict,
|
|
60
|
+
type SafetyValidator,
|
|
61
|
+
type SafetyOptions,
|
|
62
|
+
type SchemaLike,
|
|
63
|
+
} from "./safety";
|
|
64
|
+
export { estimateTokensByChars, streamTokenBudget } from "./streamTokenBudget";
|
|
65
|
+
export type { StreamTokenBudgetOptions, TokenEstimator } from "./streamTokenBudget";
|
|
66
|
+
export {
|
|
67
|
+
rateLimitMiddleware,
|
|
68
|
+
RateLimitExceededError,
|
|
69
|
+
type RateLimitOptions,
|
|
70
|
+
type RateLimitScope,
|
|
71
|
+
} from "./rateLimit";
|
|
72
|
+
export {
|
|
73
|
+
killSwitchMiddleware,
|
|
74
|
+
InMemoryKillSwitch,
|
|
75
|
+
KillSwitchEngagedError,
|
|
76
|
+
type KillScope,
|
|
77
|
+
type KillSwitchRegistry,
|
|
78
|
+
type KillVerdict,
|
|
79
|
+
} from "./killSwitchRegistry";
|