@loomwork/effect-runtime 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 +65 -2
- package/dist/EffectRuntime.d.ts +121 -0
- package/dist/EffectRuntime.d.ts.map +1 -0
- package/dist/EffectRuntime.js +422 -0
- package/dist/EffectRuntime.js.map +1 -0
- package/dist/authz.d.ts +228 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +281 -0
- package/dist/authz.js.map +1 -0
- package/dist/effects/A2AEffect.d.ts +14 -0
- package/dist/effects/A2AEffect.d.ts.map +1 -0
- package/dist/effects/A2AEffect.js +21 -0
- package/dist/effects/A2AEffect.js.map +1 -0
- package/dist/effects/HumanEffect.d.ts +13 -0
- package/dist/effects/HumanEffect.d.ts.map +1 -0
- package/dist/effects/HumanEffect.js +21 -0
- package/dist/effects/HumanEffect.js.map +1 -0
- package/dist/effects/LazyEffect.d.ts +10 -0
- package/dist/effects/LazyEffect.d.ts.map +1 -0
- package/dist/effects/LazyEffect.js +28 -0
- package/dist/effects/LazyEffect.js.map +1 -0
- package/dist/effects/LlmEffect.d.ts +27 -0
- package/dist/effects/LlmEffect.d.ts.map +1 -0
- package/dist/effects/LlmEffect.js +27 -0
- package/dist/effects/LlmEffect.js.map +1 -0
- package/dist/effects/ToolEffect.d.ts +38 -0
- package/dist/effects/ToolEffect.d.ts.map +1 -0
- package/dist/effects/ToolEffect.js +31 -0
- package/dist/effects/ToolEffect.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +76 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +212 -0
- package/dist/policy.js.map +1 -0
- package/dist/types.d.ts +416 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -3
- package/src/EffectRuntime.ts +484 -0
- package/src/authz.ts +424 -0
- package/src/effects/HumanEffect.ts +25 -0
- package/src/effects/LazyEffect.ts +25 -0
- package/src/effects/LlmEffect.ts +39 -0
- package/src/effects/ToolEffect.ts +59 -0
- package/src/index.ts +48 -0
- package/src/policy.ts +296 -0
- package/src/types.ts +428 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import type { TaintedValue } from "@loomwork/classify";
|
|
2
|
+
import type { EffectKind, EffectMode } from "@loomwork/core";
|
|
3
|
+
import type { PrincipalChain } from "./authz";
|
|
4
|
+
|
|
5
|
+
export type { EffectKind, EffectMode };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A tool was invoked from the wrong side of the determinism boundary — an
|
|
9
|
+
* `ambient` effect inside an execution, or a `journaled` effect transiently.
|
|
10
|
+
*
|
|
11
|
+
* This is a contract violation, not an authorization denial: it is thrown
|
|
12
|
+
* plainly rather than recorded as an EFFECT_DENIED fact, because the caller used
|
|
13
|
+
* the wrong entry point (a programming error) rather than lacking authority.
|
|
14
|
+
*/
|
|
15
|
+
export class EffectModeViolationError extends Error {
|
|
16
|
+
readonly toolId: string;
|
|
17
|
+
readonly declared: EffectMode;
|
|
18
|
+
/** The seam the call actually came through. */
|
|
19
|
+
readonly attempted: "journaled" | "ambient";
|
|
20
|
+
constructor(toolId: string, declared: EffectMode, attempted: "journaled" | "ambient") {
|
|
21
|
+
super(
|
|
22
|
+
declared === "ambient"
|
|
23
|
+
? `Effect "${toolId}" is declared ambient and must not be invoked inside an execution — its result would be journaled, and a call whose count/order is user-driven poisons replay. Use EffectRuntime.invokeTransient (ADR 0066).`
|
|
24
|
+
: `Effect "${toolId}" is declared journaled and must not be invoked transiently — a transient call has zero durable footprint, so the durable fact the caller expected would silently not exist. Use ctx.execute inside an execution.`
|
|
25
|
+
);
|
|
26
|
+
this.name = "EffectModeViolationError";
|
|
27
|
+
this.toolId = toolId;
|
|
28
|
+
this.declared = declared;
|
|
29
|
+
this.attempted = attempted;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Running resource totals for the owning execution (Loom 2.0 K3). Threaded into
|
|
35
|
+
* each effect's context so accounting/quota middleware can meter usage and enforce
|
|
36
|
+
* budgets. Accumulated durably in `variables._resources` via RESOURCE_METERED, so
|
|
37
|
+
* the snapshot is deterministic on replay.
|
|
38
|
+
*/
|
|
39
|
+
export interface ResourceSnapshot {
|
|
40
|
+
tokens: number;
|
|
41
|
+
costUsd: number;
|
|
42
|
+
ms: number;
|
|
43
|
+
count: number;
|
|
44
|
+
// ADR 0032 (R1) — additional dimensions; present only once something meters them.
|
|
45
|
+
memBytes?: number;
|
|
46
|
+
wallMs?: number;
|
|
47
|
+
netBytes?: number;
|
|
48
|
+
/**
|
|
49
|
+
* ADR 0115 — open metering dimensions (`gpu_seconds`, `credits`, ...), present only once
|
|
50
|
+
* something meters one. Read with `Object.hasOwn`: the keys are caller-chosen names.
|
|
51
|
+
*/
|
|
52
|
+
custom?: Readonly<Record<string, number>>;
|
|
53
|
+
/** Set once a quota has been exceeded. */
|
|
54
|
+
exceeded?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ADR 0119 — one live chunk of a streaming effect's output, delivered to the per-call
|
|
59
|
+
* sink a caller attached ({@link EffectContext.onDelta}).
|
|
60
|
+
*
|
|
61
|
+
* - TEXT chunk: `delta` carries the text (never empty), WRAPPED as untrusted content
|
|
62
|
+
* (`TaintedValue<string>`, ADR 0085): model output is third-party text, and the
|
|
63
|
+
* wrapper makes `guardRequest` refuse it if it is fed back into a prompt. A UI tail
|
|
64
|
+
* unwraps explicitly (`unwrapTainted`) to display it.
|
|
65
|
+
* - REASONING chunk (`reasoning` set, `delta` empty; ADR 0133 D6): a readable increment of the
|
|
66
|
+
* model's reasoning, a channel SEPARATE from text so no consumer of text deltas sees it by
|
|
67
|
+
* accident. Wrapped as untrusted (`llm-reasoning:<stepId>`). It passes the middleware
|
|
68
|
+
* `deltaGuard`s (a guard instance of its own, so reasoning never pollutes the running text a
|
|
69
|
+
* guard accumulates) and reaches the PER-CALL sink only, never the effect's configured
|
|
70
|
+
* `deltaSink`. Live-only and never journaled.
|
|
71
|
+
* - RESET chunk (`reset: true`, `attempt: n`, empty `delta`): attempt `n > 1` of the
|
|
72
|
+
* call is starting (retry middleware re-ran the effect) — discard everything shown
|
|
73
|
+
* so far and re-render from this attempt's deltas.
|
|
74
|
+
* - TERMINAL chunk (`done: true`, empty `delta`, `usage` if the provider reported
|
|
75
|
+
* one): sent ONLY after the call has succeeded (middleware output phase and parse
|
|
76
|
+
* included). On failure or abort there is no terminal chunk — the call's promise
|
|
77
|
+
* settlement is authoritative.
|
|
78
|
+
*/
|
|
79
|
+
export interface EffectDelta {
|
|
80
|
+
readonly delta: TaintedValue<string>;
|
|
81
|
+
/** ADR 0133 D6 - a reasoning increment; `delta` is empty on such a chunk. */
|
|
82
|
+
readonly reasoning?: TaintedValue<string>;
|
|
83
|
+
readonly done?: true;
|
|
84
|
+
readonly reset?: true;
|
|
85
|
+
readonly attempt?: number;
|
|
86
|
+
readonly usage?: { readonly inputTokens: number; readonly outputTokens: number };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A per-call, live-only, synchronous, fire-and-forget delta sink (ADR 0119). */
|
|
90
|
+
export type DeltaSink = (chunk: EffectDelta) => void;
|
|
91
|
+
|
|
92
|
+
export interface EffectContext {
|
|
93
|
+
executionId: string;
|
|
94
|
+
stepId: string;
|
|
95
|
+
workflowId: string;
|
|
96
|
+
attempt: number;
|
|
97
|
+
/**
|
|
98
|
+
* The resolved effect id for this invocation. The runtime sets it on the view
|
|
99
|
+
* of the context the MIDDLEWARE chain receives (additive — hand-built contexts
|
|
100
|
+
* simply leave it unset, and the effect itself still receives the caller's
|
|
101
|
+
* context unchanged). Lets cross-cutting middleware key per-tool configuration
|
|
102
|
+
* — e.g. claim-check digest declarations — without a lookup side channel.
|
|
103
|
+
*/
|
|
104
|
+
readonly toolId?: string;
|
|
105
|
+
/**
|
|
106
|
+
* ADR 0140 - the `TaskDefinition.kind` of the durable frame this effect runs in (`"tool"` for an
|
|
107
|
+
* `agentLoop$` tool call, `"llm"` for its model turn, `"memory"` for its compaction), set by the workflow
|
|
108
|
+
* runtime on durable steps. Absent on a transient (`invokeTransient`) call and on hand-built contexts.
|
|
109
|
+
* Lets middleware that must touch only an agent's TOOL frames (the opt-in claim-check) tell them from
|
|
110
|
+
* a model or summarizer call that shares no id vocabulary with them.
|
|
111
|
+
*/
|
|
112
|
+
readonly taskKind?: string;
|
|
113
|
+
/**
|
|
114
|
+
* L30 — set only on a transient call stamped as an execution (`ctx.decide`'s
|
|
115
|
+
* scope). Several such calls share `(executionId, stepId = toolId)`, so a site
|
|
116
|
+
* that mints an id from those two (A2A task id, redaction secret-ref id) MUST
|
|
117
|
+
* append this when present, or two calls collide. Undefined on durable steps
|
|
118
|
+
* (their stepId is unique) and on bare transient calls.
|
|
119
|
+
*/
|
|
120
|
+
readonly callKey?: string;
|
|
121
|
+
/**
|
|
122
|
+
* ADR 0119 — the per-call delta sink of a transient call that asked for one. A
|
|
123
|
+
* streaming effect (`createLlmEffect({ stream: true })`) calls it for each delta;
|
|
124
|
+
* the runtime forwards to the effect's CONFIGURED sink ({@link Effect.deltaSink}) from
|
|
125
|
+
* here, after the guards, so the configured sink is governed on every path. Live-only: never journaled, and not
|
|
126
|
+
* called on replay. The runtime hands the effect a wrapper that goes silent once the
|
|
127
|
+
* call settles or aborts and swallows a throw of the CALLER's sink. It throws in ONE
|
|
128
|
+
* case: a middleware delta guard (e.g. a safety delta validator) refused the running
|
|
129
|
+
* text — that throw means "sever the stream", so an effect MUST let it propagate
|
|
130
|
+
* (do not catch it around the call). Absent when no runtime is
|
|
131
|
+
* involved or neither a caller nor the effect has a sink. When the runtime REFUSED streaming
|
|
132
|
+
* (an output validator has no delta form) it is a silent no-op — never absent.
|
|
133
|
+
*/
|
|
134
|
+
readonly onDelta?: DeltaSink;
|
|
135
|
+
emit(eventType: string, payload: unknown): void;
|
|
136
|
+
/**
|
|
137
|
+
* Cooperative cancellation for this effect. Aborts when the owning execution is
|
|
138
|
+
* cancelled. Long-running/I/O effects (LLM, tool, A2A) should forward it to
|
|
139
|
+
* `fetch`/providers and check it at await points. Optional and undefined by
|
|
140
|
+
* default, so non-cancellable effects pay nothing.
|
|
141
|
+
*/
|
|
142
|
+
readonly signal?: AbortSignal;
|
|
143
|
+
/**
|
|
144
|
+
* Running resource totals for this execution BEFORE this effect (K3). Undefined
|
|
145
|
+
* when accounting is not wired, so effects/middleware that don't meter pay nothing.
|
|
146
|
+
*/
|
|
147
|
+
readonly resources?: ResourceSnapshot;
|
|
148
|
+
/**
|
|
149
|
+
* K9.2 — deterministic idempotency key for this effect invocation:
|
|
150
|
+
* `executionId:stepId` (no attempt component — the folded attempt counter
|
|
151
|
+
* advances on crash-recovery re-execution, which is exactly when the key must
|
|
152
|
+
* stay stable). Recovery re-runs of the same logical attempt therefore carry
|
|
153
|
+
* the SAME key, so tool authors can deduplicate at the provider. A fresh key
|
|
154
|
+
* (`executionId:stepId:rN`) is minted only by a durable new-logical-attempt
|
|
155
|
+
* fact — an EFFECT_INDOUBT "retry-fresh" verdict.
|
|
156
|
+
*/
|
|
157
|
+
readonly idempotencyKey?: string;
|
|
158
|
+
/**
|
|
159
|
+
* Scheduling priority for this effect (M6), used when the EffectRuntime routes
|
|
160
|
+
* effects through a Scheduler with a finite concurrency cap. A `@loomwork/scheduler`
|
|
161
|
+
* Priority value (0 = Immediate … 3 = Idle). Undefined ⇒ Normal.
|
|
162
|
+
*/
|
|
163
|
+
readonly priority?: number;
|
|
164
|
+
/**
|
|
165
|
+
* K12.1 — the authenticated principal chain for the owning execution, carried
|
|
166
|
+
* opaquely and handed to the K2 authorizer. Set at the trust boundary
|
|
167
|
+
* (start/resume). Undefined ⇒ no principal (today's behavior).
|
|
168
|
+
*/
|
|
169
|
+
readonly principal?: PrincipalChain;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Options for a transient (non-durable) effect invocation — {@link
|
|
174
|
+
* EffectRuntime.invokeTransient}. Every field is optional; the empty case is a
|
|
175
|
+
* bare one-shot call with no cancellation and no principal.
|
|
176
|
+
*
|
|
177
|
+
* There is deliberately no `executionId`/`stepId`/`attempt`/`emit` here: those
|
|
178
|
+
* are the internal {@link EffectContext} shape the façade OWNS and synthesizes,
|
|
179
|
+
* so a caller never couples to it (R2). None of these fields is ever persisted —
|
|
180
|
+
* a transient invocation writes nothing (R1).
|
|
181
|
+
*/
|
|
182
|
+
export interface TransientInvokeOptions {
|
|
183
|
+
/**
|
|
184
|
+
* Optional cooperative cancellation (R5). Forwarded verbatim as
|
|
185
|
+
* `EffectContext.signal`, so it propagates to the effect's `fetch`/sandbox/
|
|
186
|
+
* provider and aborts the in-flight call. Lets a caller bound a transient
|
|
187
|
+
* invocation with a deadline.
|
|
188
|
+
*/
|
|
189
|
+
readonly signal?: AbortSignal;
|
|
190
|
+
/**
|
|
191
|
+
* Optional authenticated principal chain. If an authorizer is wired on the
|
|
192
|
+
* runtime (K2 protection ring), it is still enforced transiently — an
|
|
193
|
+
* unauthorized effect never runs. Undefined ⇒ no principal (today's behavior).
|
|
194
|
+
*/
|
|
195
|
+
readonly principal?: PrincipalChain;
|
|
196
|
+
/**
|
|
197
|
+
* A trace/metric tag, never persisted (there is nothing to persist to).
|
|
198
|
+
* Defaults to the `toolId`. Without `as` it is NOT only a tag: it becomes the
|
|
199
|
+
* synthetic context's `executionId` (`transient:<label>`), which the authorizer
|
|
200
|
+
* and middleware see — a `GrantTable`/`PolicyAuthorizer` configured to grant
|
|
201
|
+
* per execution decides on it. Wrappers that memoise must key on it.
|
|
202
|
+
*/
|
|
203
|
+
readonly label?: string;
|
|
204
|
+
/**
|
|
205
|
+
* L30 — run AS an existing execution instead of the throwaway `transient`
|
|
206
|
+
* identity: the synthesized {@link EffectContext} carries this `workflowId`/
|
|
207
|
+
* `executionId`, so the K2 authorizer's flow-scoped grants, delegation checks and
|
|
208
|
+
* trace/middleware correlation see the deciding execution. It changes only WHAT
|
|
209
|
+
* IS CLAIMED, never what is written: the call is still transient (R1-R5
|
|
210
|
+
* unchanged — no journal, no-op `emit`, same middleware chain, `ambient`
|
|
211
|
+
* effect-mode check). `stepId` defaults to the toolId, exactly as a bare call, so
|
|
212
|
+
* stepId-keyed per-tool configuration (redaction, pricing, retry) matches.
|
|
213
|
+
*
|
|
214
|
+
* Trust model: this is the transient analogue of the EffectContext a holder of
|
|
215
|
+
* the runtime can already hand-build via `execute`; it is NOT a capability check.
|
|
216
|
+
* `ctx.decide`'s scope is the only thing flow code is handed, and it pre-stamps
|
|
217
|
+
* the identity, so flow code cannot choose one. Do not forward untrusted input
|
|
218
|
+
* into `as`.
|
|
219
|
+
*
|
|
220
|
+
* Because the identity is an AUTHORIZATION INPUT (`GrantTable` keys on
|
|
221
|
+
* `workflowId`), any wrapper that memoises, dedupes or caches transient calls
|
|
222
|
+
* MUST key on the identity (`as`) as well as `[principal, input]` — and on
|
|
223
|
+
* `label` too, which names the transient execution when `as` is absent (the
|
|
224
|
+
* simplest safe key is every option except `signal`) — otherwise a result
|
|
225
|
+
* produced under one execution's grants can be served to another's.
|
|
226
|
+
*/
|
|
227
|
+
readonly as?: TransientIdentity;
|
|
228
|
+
/**
|
|
229
|
+
* Running resource totals of the execution the call is stamped as, so metering /
|
|
230
|
+
* `nestedBudget` ADMISSION control can refuse an over-budget execution. The
|
|
231
|
+
* CHARGE is not recorded: `emit` is the no-op sink, so a transient call's spend
|
|
232
|
+
* never reaches the fold (a decider's LLM spend is invisible to the budget).
|
|
233
|
+
*/
|
|
234
|
+
readonly resources?: ResourceSnapshot;
|
|
235
|
+
/**
|
|
236
|
+
* ADR 0119 — a live delta sink for this call, surfaced as `EffectContext.onDelta`.
|
|
237
|
+
* Synchronous and fire-and-forget; a throw is swallowed (and warned), never fails
|
|
238
|
+
* the call. Does not change what the call records (nothing — it is transient).
|
|
239
|
+
*/
|
|
240
|
+
readonly onDelta?: DeltaSink;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The execution identity a transient invocation is stamped with (L30). */
|
|
244
|
+
export interface TransientIdentity {
|
|
245
|
+
readonly workflowId: string;
|
|
246
|
+
readonly executionId: string;
|
|
247
|
+
/** Defaults to the toolId. Keep it that way if per-tool config is keyed by stepId. */
|
|
248
|
+
readonly stepId?: string;
|
|
249
|
+
/**
|
|
250
|
+
* Disambiguates calls that share `(executionId, stepId)` (several calls to one
|
|
251
|
+
* tool within an execution): surfaces as {@link EffectContext.callKey}.
|
|
252
|
+
*/
|
|
253
|
+
readonly callKey?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Advisory pre-warm hook (M6). Called off the durable path before an effect runs so a
|
|
258
|
+
* backend can keep a model/connection/cache hot. It is latency-only — it MUST NOT emit
|
|
259
|
+
* events or influence outcomes (P10 advisory plane). A failed/absent warm just means a
|
|
260
|
+
* cold call.
|
|
261
|
+
*/
|
|
262
|
+
export interface Warmer {
|
|
263
|
+
warm(toolId: string): void;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Minimal structural view of a Zod schema's `safeParse` — kept structural so
|
|
268
|
+
* `@loomwork/effect-runtime` stays zod-free (the same dependency-light idiom as
|
|
269
|
+
* core's AgentSkillDef). A `Record<string, z.ZodSchema>` satisfies it directly.
|
|
270
|
+
*/
|
|
271
|
+
export interface ErrorChannelSchema {
|
|
272
|
+
safeParse(
|
|
273
|
+
input: unknown
|
|
274
|
+
): { success: true; data: unknown } | { success: false; error: { message: string } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* ADR 0133 D7 - app-level defaults for model-reasoning capture, structural so the composition root
|
|
279
|
+
* needs no dependency on `@loomwork/llm`. `store` is the sidecar where readable reasoning is kept
|
|
280
|
+
* (a content store satisfies it); `capture` defaults to on iff a store is configured.
|
|
281
|
+
*/
|
|
282
|
+
export interface EffectReasoningDefaults {
|
|
283
|
+
store?: {
|
|
284
|
+
put(bytes: Uint8Array): Promise<{ readonly __loomContent: string }>;
|
|
285
|
+
get(ref: { readonly __loomContent: string }): Promise<Uint8Array | null>;
|
|
286
|
+
};
|
|
287
|
+
capture?: boolean | ((ctx: EffectContext) => boolean);
|
|
288
|
+
live?: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* ADR 0133 D7 - what the composition root may ask of an LLM effect about model reasoning: whether its
|
|
293
|
+
* provider can return any, how it is configured now, and to adopt app-level defaults (the effect's
|
|
294
|
+
* own settings win field by field). Not part of the call path.
|
|
295
|
+
*/
|
|
296
|
+
export interface EffectReasoningHook {
|
|
297
|
+
/** The provider declares `capabilities.reasoning`. */
|
|
298
|
+
readonly capable: boolean;
|
|
299
|
+
/** `keeps`: a store is configured and capture is not off. `off`: capture is explicitly off. `unconfigured`: neither. */
|
|
300
|
+
posture(): "keeps" | "off" | "unconfigured";
|
|
301
|
+
/** Adopt defaults; `false` is the explicit, silent opt-out (capture off) for whatever the effect did not set itself. */
|
|
302
|
+
adopt(defaults: EffectReasoningDefaults | false): void;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface Effect<TIn = unknown, TOut = unknown> {
|
|
306
|
+
kind: EffectKind;
|
|
307
|
+
/** ADR 0133 D7 - present on an LLM effect built by `createLlmEffect`. */
|
|
308
|
+
readonly reasoning?: EffectReasoningHook;
|
|
309
|
+
/**
|
|
310
|
+
* K9.3 — the effect's declared delivery semantic. `false` marks a mutating,
|
|
311
|
+
* non-idempotent effect: recovery refuses to silently re-run an attempt in an
|
|
312
|
+
* unknown state (it parks the execution at EFFECT_INDOUBT for an operator
|
|
313
|
+
* verdict). `true` asserts re-runs are safe (dedupe at the provider via
|
|
314
|
+
* `EffectContext.idempotencyKey`). Undeclared (`undefined`) keeps the legacy
|
|
315
|
+
* at-least-once re-run — existing effects behave byte-identically (G2).
|
|
316
|
+
*/
|
|
317
|
+
readonly idempotent?: boolean;
|
|
318
|
+
/**
|
|
319
|
+
* DE-4 (ADR 0060) — the effect's declared failure channels (channel name →
|
|
320
|
+
* payload schema). Like `idempotent`, a declaration the kernel reads from the
|
|
321
|
+
* registry: a `TypedFailure` thrown on a declared channel is recorded as a
|
|
322
|
+
* typed STEP_FAILED and substituted on replay. A step-level `errors:`
|
|
323
|
+
* declaration wins over this registry fallback. Undeclared channels keep the
|
|
324
|
+
* legacy untyped at-least-once failure semantics (G2).
|
|
325
|
+
*/
|
|
326
|
+
readonly errors?: Record<string, ErrorChannelSchema>;
|
|
327
|
+
/**
|
|
328
|
+
* Which invocation seam this effect belongs to — `"journaled"` (inside an
|
|
329
|
+
* execution) or `"ambient"` (transient only). Like `idempotent`, a declaration
|
|
330
|
+
* the runtime reads from the registry: `execute` refuses an `ambient` effect
|
|
331
|
+
* and `invokeTransient` refuses a `"journaled"` one, both with
|
|
332
|
+
* {@link EffectModeViolationError}. Undeclared keeps today's behaviour on both
|
|
333
|
+
* paths (G2).
|
|
334
|
+
*
|
|
335
|
+
* This is a boundary check, not a journaling mode: an `ambient` effect is not
|
|
336
|
+
* "an effect that skips its journal" — it is one that may not enter an
|
|
337
|
+
* execution at all, so there remains exactly one replay model.
|
|
338
|
+
*/
|
|
339
|
+
readonly mode?: EffectMode;
|
|
340
|
+
/**
|
|
341
|
+
* ADR 0119 — the effect's own CONFIGURED live-delta sink (e.g. `createLlmEffect({ onDelta })`).
|
|
342
|
+
* Declared here so the RUNTIME owns delivery: the effect calls `ctx.onDelta` and the runtime
|
|
343
|
+
* forwards text and reset chunks (never the terminal one) to this sink only AFTER the
|
|
344
|
+
* middleware delta guards ran, on durable and transient calls alike; it is silent (fail
|
|
345
|
+
* closed) under an output validator that has no delta form. Called directly by the effect
|
|
346
|
+
* only when there is no runtime (`ctx.onDelta` absent).
|
|
347
|
+
*/
|
|
348
|
+
readonly deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
|
|
349
|
+
/**
|
|
350
|
+
* ADR 0133 D6 - the effect's configured live REASONING sink (e.g. `createLlmEffect({ reasoning:
|
|
351
|
+
* { live: true, onDelta } })`). Reasoning is a channel of its own, so it never reaches
|
|
352
|
+
* {@link deltaSink}; the runtime forwards a reasoning increment here only AFTER the middleware
|
|
353
|
+
* delta guards ran, still wrapped as untrusted content, and is silent under an output validator
|
|
354
|
+
* that has no delta form (fail closed), exactly like the text sink.
|
|
355
|
+
*/
|
|
356
|
+
readonly reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
|
|
357
|
+
/**
|
|
358
|
+
* R12 — the sub-resource one call of this effect targets, for per-resource authorization.
|
|
359
|
+
* ONE registered effect can front many resources (`mcp.invoke` fronts every tool on an MCP
|
|
360
|
+
* server); an authorizer that only sees the effect id can allow all of them or none. When
|
|
361
|
+
* declared, the runtime evaluates it on the call's input and puts the result on
|
|
362
|
+
* `CapabilityRequest.resource`, so a grant or policy can name `<effectId>:<resource>`.
|
|
363
|
+
*
|
|
364
|
+
* Pure and cheap — it runs before the effect, on the raw (pre-redaction) input. Returning
|
|
365
|
+
* `undefined` (or throwing) means "no resource": only an effect-wide grant then applies, so a
|
|
366
|
+
* per-resource grant fails CLOSED rather than matching nothing-in-particular.
|
|
367
|
+
*
|
|
368
|
+
* It is evaluated on the input as the call site passed it, BEFORE any middleware runs: a
|
|
369
|
+
* middleware that rewrites the input (e.g. the tool name) after authorization is not seen by
|
|
370
|
+
* the authorizer, so never rely on a middleware to change what the authorization decision
|
|
371
|
+
* names.
|
|
372
|
+
*/
|
|
373
|
+
resourceOf?(input: TIn): string | undefined;
|
|
374
|
+
/**
|
|
375
|
+
* Advisory pre-warm (latency-only, off the durable path). If present, the
|
|
376
|
+
* kernel may call it BEFORE the effect is needed to spin up a cold resource
|
|
377
|
+
* the effect depends on — a K7 isolated child process, an LLM provider
|
|
378
|
+
* connection/model cache, a remote worker. Like the {@link Warmer} seam it is
|
|
379
|
+
* strictly latency: it MUST NOT emit events or influence outcomes (P10), and a
|
|
380
|
+
* failure is a silent no-op → the first real call just runs cold. Idempotent
|
|
381
|
+
* and safe to call repeatedly.
|
|
382
|
+
*/
|
|
383
|
+
warm?(): void | Promise<void>;
|
|
384
|
+
execute(input: TIn, ctx: EffectContext): Promise<TOut>;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Invokes the downstream middleware, or finally the effect itself. Call `next()`
|
|
389
|
+
* to pass this middleware's input through unchanged; call `next(override)` to
|
|
390
|
+
* feed a TRANSFORMED input to the effect (e.g. K10.3 redaction resolving secret
|
|
391
|
+
* references). The override flows to the effect body, not back to the log.
|
|
392
|
+
*/
|
|
393
|
+
export type EffectNext = (input?: unknown) => Promise<unknown>;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Wraps a single effect execution — the cross-cutting seam (retries, logging,
|
|
397
|
+
* tracing, idempotency) around every tool/LLM/human/a2a call.
|
|
398
|
+
*
|
|
399
|
+
* Replay-safety: middleware runs ONLY when an effect actually executes. Completed
|
|
400
|
+
* steps short-circuit in `WorkflowContext.run` before `EffectRuntime.execute` is
|
|
401
|
+
* called, so replayed/resumed runs never re-invoke middleware for work already in
|
|
402
|
+
* the log. The one in-flight step that re-runs on crash-recovery re-runs its
|
|
403
|
+
* middleware too, so any middleware with external side effects must tolerate
|
|
404
|
+
* at-least-once (prefer emitting durable events via `ctx.emit`).
|
|
405
|
+
*
|
|
406
|
+
* Ordering is static (fixed at `EffectRuntime` construction) so it is
|
|
407
|
+
* deterministic across replay: `middleware[0]` is outermost.
|
|
408
|
+
*/
|
|
409
|
+
export interface EffectMiddleware {
|
|
410
|
+
(ctx: EffectContext, input: unknown, next: EffectNext): Promise<unknown>;
|
|
411
|
+
/**
|
|
412
|
+
* ADR 0119 — declares that this middleware INSPECTS THE OUTPUT of the effect (an
|
|
413
|
+
* output-phase safety validator). A streamed call would deliver text to a live sink
|
|
414
|
+
* before that inspection could run, so: if any middleware in the chain has
|
|
415
|
+
* `inspectsOutput: true`, the runtime REFUSES streaming to BOTH the per-call sink and
|
|
416
|
+
* the effect's configured `deltaSink` (warns; the call completes non-streamed). Fail-closed — no configuration streams text past an
|
|
417
|
+
* output check that cannot see it. A middleware whose every output check has a delta
|
|
418
|
+
* form covers it with {@link deltaGuard} and leaves this unset.
|
|
419
|
+
*/
|
|
420
|
+
readonly inspectsOutput?: boolean;
|
|
421
|
+
/**
|
|
422
|
+
* ADR 0119 — a per-call-attempt guard over the RUNNING text of a streamed call.
|
|
423
|
+
* Called once per attempt to build a `(delta) => void`, which the runtime invokes for
|
|
424
|
+
* each text delta BEFORE it is delivered; it THROWS to sever the stream (the
|
|
425
|
+
* offending delta is not delivered).
|
|
426
|
+
*/
|
|
427
|
+
readonly deltaGuard?: (ctx: EffectContext) => (delta: string) => void;
|
|
428
|
+
}
|