@loomwork/effect-runtime 0.0.0-stage → 0.1.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/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
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { type TaintedValue, isTainted, tainted, unwrapTainted } from "@loomwork/classify";
|
|
2
|
+
import { VOCABULARY_NAME_PATTERN, isVocabularyName } from "@loomwork/core";
|
|
3
|
+
import { Priority, type Scheduler } from "@loomwork/scheduler";
|
|
4
|
+
import {
|
|
5
|
+
type CapabilityAuthorizer,
|
|
6
|
+
CapabilityDeniedError,
|
|
7
|
+
type EffectRuntimeOptions,
|
|
8
|
+
principalBlocked,
|
|
9
|
+
} from "./authz";
|
|
10
|
+
import { EffectModeViolationError } from "./types";
|
|
11
|
+
import type {
|
|
12
|
+
DeltaSink,
|
|
13
|
+
Effect,
|
|
14
|
+
EffectContext,
|
|
15
|
+
EffectDelta,
|
|
16
|
+
EffectMiddleware,
|
|
17
|
+
EffectNext,
|
|
18
|
+
ErrorChannelSchema,
|
|
19
|
+
TransientInvokeOptions,
|
|
20
|
+
Warmer,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
export class EffectRuntime {
|
|
24
|
+
private readonly registry = new Map<string, Effect>();
|
|
25
|
+
private readonly middleware: readonly EffectMiddleware[];
|
|
26
|
+
private readonly authorizer: CapabilityAuthorizer | undefined;
|
|
27
|
+
private readonly scheduler: Scheduler | undefined;
|
|
28
|
+
private readonly warmer: Warmer | undefined;
|
|
29
|
+
private readonly onWarn: (message: string) => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param middleware Cross-cutting middleware applied around every effect
|
|
33
|
+
* execution, in order — `middleware[0]` is outermost. The list is fixed here
|
|
34
|
+
* (static ⇒ deterministic across replay). An empty list (the default) makes
|
|
35
|
+
* `execute` behave identically to a bare effect call.
|
|
36
|
+
* @param options Optional `authorizer` (K2 protection ring), `scheduler` (M6 —
|
|
37
|
+
* route effects through it for bounded concurrency + priority), and `warmer` (M6 —
|
|
38
|
+
* advisory off-log pre-warm). All omittable; the empty case is the zero-overhead
|
|
39
|
+
* direct path with today's behaviour.
|
|
40
|
+
*/
|
|
41
|
+
constructor(middleware: readonly EffectMiddleware[] = [], options: EffectRuntimeOptions = {}) {
|
|
42
|
+
this.middleware = middleware;
|
|
43
|
+
this.authorizer = options.authorizer;
|
|
44
|
+
this.scheduler = options.scheduler;
|
|
45
|
+
this.warmer = options.warmer;
|
|
46
|
+
this.onWarn = options.onWarn ?? ((m) => console.warn(m));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
register(toolId: string, effect: Effect): void {
|
|
50
|
+
// ADR 0115 — a kind is an open vocabulary, but a malformed one is refused here, where the
|
|
51
|
+
// effect is declared, not later where the kind is used as a map key or a metrics label.
|
|
52
|
+
if (!isVocabularyName(effect.kind)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Effect "${toolId}" declares kind ${JSON.stringify(effect.kind)}: a kind must match ${String(VOCABULARY_NAME_PATTERN)} (ADR 0115)`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.registry.set(toolId, effect);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async execute<TIn = unknown, TOut = unknown>(
|
|
61
|
+
toolId: string,
|
|
62
|
+
input: TIn,
|
|
63
|
+
ctx: EffectContext
|
|
64
|
+
): Promise<TOut> {
|
|
65
|
+
// ADR 0119 (review 2) — a durable call whose effect has a CONFIGURED delta sink gets the
|
|
66
|
+
// same guarded channel as a transient one: the configured sink is reached ONLY through
|
|
67
|
+
// the middleware delta guards (and fails closed under an uncovered output validator).
|
|
68
|
+
// Deltas stay live-only — never journaled.
|
|
69
|
+
const pipe = ctx.onDelta === undefined ? this.deltaPipe(toolId, ctx, undefined) : undefined;
|
|
70
|
+
if (pipe === undefined) return this.runSeam<TIn, TOut>(toolId, input, ctx, "journaled");
|
|
71
|
+
try {
|
|
72
|
+
return await this.runSeam<TIn, TOut>(
|
|
73
|
+
toolId,
|
|
74
|
+
input,
|
|
75
|
+
{ ...ctx, onDelta: pipe.sink },
|
|
76
|
+
"journaled",
|
|
77
|
+
pipe.onExecute
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
pipe.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// R12 — the sub-resource a call targets, for per-resource authorization. Never throws: a
|
|
85
|
+
// resolver that fails yields "no resource", which only an effect-wide grant satisfies.
|
|
86
|
+
private resourceOf(toolId: string, input: unknown): string | undefined {
|
|
87
|
+
const effect = this.registry.get(toolId);
|
|
88
|
+
if (effect?.resourceOf === undefined) return undefined;
|
|
89
|
+
try {
|
|
90
|
+
const r = effect.resourceOf(input);
|
|
91
|
+
return typeof r === "string" && r.length > 0 ? r : undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The shared execute path for both seams. `seam` records which entry point the
|
|
98
|
+
// caller came through, so the effect-mode boundary is enforced in exactly one
|
|
99
|
+
// place and cannot be spoofed by hand-building a context that merely *looks*
|
|
100
|
+
// transient. Everything else — K2 authz, the middleware chain, K8 cancellation
|
|
101
|
+
// — is identical on both paths.
|
|
102
|
+
private async runSeam<TIn = unknown, TOut = unknown>(
|
|
103
|
+
toolId: string,
|
|
104
|
+
input: TIn,
|
|
105
|
+
ctx: EffectContext,
|
|
106
|
+
seam: "journaled" | "ambient",
|
|
107
|
+
onExecute?: () => void
|
|
108
|
+
): Promise<TOut> {
|
|
109
|
+
// R3k (ADR 0111 D4) — the KERNEL refuses a principal whose recorded identity could not be
|
|
110
|
+
// restored (an unrestorable delegation, a shredded or unresolvable record), BEFORE any
|
|
111
|
+
// authorizer: correctness must not depend on which authorizer happens to be composed.
|
|
112
|
+
const blocked = principalBlocked(ctx.principal);
|
|
113
|
+
if (blocked !== undefined) {
|
|
114
|
+
ctx.emit("EFFECT_DENIED", { toolId, stepId: ctx.stepId, reason: blocked });
|
|
115
|
+
throw new CapabilityDeniedError(toolId, blocked);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Protection ring first: an unauthorized effect never runs, and its denial is a
|
|
119
|
+
// durable, auditable fact (P9). Checked before registry lookup so probing for
|
|
120
|
+
// unregistered effects is denied too.
|
|
121
|
+
if (this.authorizer !== undefined) {
|
|
122
|
+
const resource = this.resourceOf(toolId, input);
|
|
123
|
+
const decision = this.authorizer.authorize({
|
|
124
|
+
toolId,
|
|
125
|
+
...(resource !== undefined && { resource }),
|
|
126
|
+
executionId: ctx.executionId,
|
|
127
|
+
workflowId: ctx.workflowId,
|
|
128
|
+
stepId: ctx.stepId,
|
|
129
|
+
...(ctx.principal !== undefined && { principal: ctx.principal }),
|
|
130
|
+
});
|
|
131
|
+
if (!decision.allowed) {
|
|
132
|
+
const reason = decision.reason ?? "not authorized";
|
|
133
|
+
ctx.emit("EFFECT_DENIED", { toolId, stepId: ctx.stepId, reason });
|
|
134
|
+
throw new CapabilityDeniedError(toolId, reason);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const effect = this.registry.get(toolId);
|
|
139
|
+
if (!effect) {
|
|
140
|
+
throw new Error(`No effect registered for toolId: "${toolId}"`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The determinism-boundary check (ADR 0073). A declared mode pins the effect
|
|
144
|
+
// to one seam: `ambient` reads (pagination, drill-down, a surface refetch)
|
|
145
|
+
// must never append to the log, and a `journaled` effect invoked transiently
|
|
146
|
+
// would silently produce no durable fact. Undeclared effects are unaffected,
|
|
147
|
+
// so this is byte-identical for everything that has not opted in (G2).
|
|
148
|
+
if (effect.mode !== undefined && effect.mode !== seam) {
|
|
149
|
+
throw new EffectModeViolationError(toolId, effect.mode, seam);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// K8 — a cancelled/deadline-breached execution never starts another effect:
|
|
153
|
+
// fail fast before the middleware chain rather than relying on every effect
|
|
154
|
+
// body to check the signal.
|
|
155
|
+
if (ctx.signal?.aborted === true) {
|
|
156
|
+
throw ctx.signal.reason instanceof Error
|
|
157
|
+
? ctx.signal.reason
|
|
158
|
+
: new Error(`effect "${toolId}" aborted before start`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build the effect+middleware chain as a single thunk. Fold right-to-left so
|
|
162
|
+
// middleware[0] ends up outermost (its `next` runs middleware[1], … the effect last).
|
|
163
|
+
// `next(override?)` threads an optional transformed input down the chain
|
|
164
|
+
// (K10.3 redaction resolves secret references into the value the effect sees);
|
|
165
|
+
// middleware that pass their received input unchanged just call `next()`.
|
|
166
|
+
// Middleware see the resolved toolId on their context view (per-tool config,
|
|
167
|
+
// e.g. claim-check digests); the effect receives the caller's ctx unchanged.
|
|
168
|
+
const mwCtx: EffectContext =
|
|
169
|
+
this.middleware.length > 0 && ctx.toolId === undefined ? { ...ctx, toolId } : ctx;
|
|
170
|
+
let chain: EffectNext = (inp: unknown = input) => {
|
|
171
|
+
// ADR 0119 — one tick per effect.execute (a retry middleware re-enters here).
|
|
172
|
+
onExecute?.();
|
|
173
|
+
return effect.execute(inp, ctx);
|
|
174
|
+
};
|
|
175
|
+
for (let i = this.middleware.length - 1; i >= 0; i--) {
|
|
176
|
+
const mw = this.middleware[i]!;
|
|
177
|
+
const downstream = chain;
|
|
178
|
+
chain = (inp: unknown = input) =>
|
|
179
|
+
mw(mwCtx, inp, (override?: unknown) => downstream(override ?? inp));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Advisory pre-warm (M6) — off the durable path, fire-and-forget, no event.
|
|
183
|
+
this.warmer?.warm(toolId);
|
|
184
|
+
|
|
185
|
+
// M6 — route through the scheduler for bounded concurrency + priority ordering
|
|
186
|
+
// when configured; otherwise the zero-overhead direct path (unchanged).
|
|
187
|
+
if (this.scheduler !== undefined) {
|
|
188
|
+
const priority = (ctx.priority ?? Priority.Normal) as Priority;
|
|
189
|
+
return this.scheduler.schedule(() => chain(), {
|
|
190
|
+
priority,
|
|
191
|
+
label: toolId,
|
|
192
|
+
...(ctx.signal !== undefined && { signal: ctx.signal }),
|
|
193
|
+
}) as Promise<TOut>;
|
|
194
|
+
}
|
|
195
|
+
return chain() as Promise<TOut>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Run a single registered effect ONCE, off the durable path — the supported
|
|
200
|
+
* façade over the already-blessed transient invocation (DE-series eval; ADR
|
|
201
|
+
* 0065 "don't make it a run until something durable happens"). It is exactly
|
|
202
|
+
* {@link execute}, but the framework owns the {@link EffectContext} instead of
|
|
203
|
+
* every caller hand-rolling one.
|
|
204
|
+
*
|
|
205
|
+
* What it does NOT do — the load-bearing guarantee (R1): nothing is written.
|
|
206
|
+
* The EffectRuntime holds no event store, no journal, and no fence, so a
|
|
207
|
+
* transient invocation produces zero envelopes, no `EXECUTION_STARTED`, no
|
|
208
|
+
* `STEP_*`, and no snapshot — structurally, not by convention. This is the
|
|
209
|
+
* sanctioned side of the ADR 0065 line: no execution, no fold, no recovery.
|
|
210
|
+
*
|
|
211
|
+
* Middleware contract (R3): the SAME statically configured effect-runtime
|
|
212
|
+
* middleware chain that a durable step's effect call runs also runs here —
|
|
213
|
+
* retry/backoff and the K2 authorizer are effect-local and remain in force.
|
|
214
|
+
* What is bypassed is everything ABOVE this layer: the workflow interpreter's
|
|
215
|
+
* journaling/durability (fold, `ExecutionJournal`, fencing, snapshots) is not
|
|
216
|
+
* EffectRuntime middleware at all, so there is nothing to opt out of.
|
|
217
|
+
*
|
|
218
|
+
* `ctx.emit` is a no-op sink (R4): an effect that emits transiently has the
|
|
219
|
+
* event DROPPED by construction — never buffered, never later flushed. Do not
|
|
220
|
+
* author a transient effect that relies on emitting durable state.
|
|
221
|
+
*
|
|
222
|
+
* The synthesized identity is throwaway: `idempotencyKey` is left unset, so do
|
|
223
|
+
* NOT rely on provider-side dedup across transient calls (they carry no stable
|
|
224
|
+
* key — a transient read path is expected to be retry-on-client).
|
|
225
|
+
*
|
|
226
|
+
* L30: `opts.as` stamps the call with an existing execution's identity (used by
|
|
227
|
+
* `ctx.decide`'s scope); it changes the claimed identity only, not R1-R5.
|
|
228
|
+
*
|
|
229
|
+
* @param opts optional {@link TransientInvokeOptions} — `signal` (cancellation,
|
|
230
|
+
* R5), `principal` (authz still enforced if wired), `label` (trace tag; without
|
|
231
|
+
* `as` also the synthetic executionId an authorizer may grant by).
|
|
232
|
+
*/
|
|
233
|
+
async invokeTransient<TIn = unknown, TOut = unknown>(
|
|
234
|
+
toolId: string,
|
|
235
|
+
input: TIn,
|
|
236
|
+
opts: TransientInvokeOptions = {}
|
|
237
|
+
): Promise<TOut> {
|
|
238
|
+
// Synthesize the throwaway context the caller must never build (R2). The ids
|
|
239
|
+
// are meaningful only for the lifetime of this call: `executionId` carries
|
|
240
|
+
// the trace label for middleware correlation, `stepId` the toolId. `emit` is
|
|
241
|
+
// the drop sink (R4) — including the authz-denial emit, which still throws.
|
|
242
|
+
const base: EffectContext = {
|
|
243
|
+
executionId: opts.as?.executionId ?? `transient:${opts.label ?? toolId}`,
|
|
244
|
+
stepId: opts.as?.stepId ?? toolId,
|
|
245
|
+
workflowId: opts.as?.workflowId ?? "transient",
|
|
246
|
+
attempt: 1,
|
|
247
|
+
emit: () => {},
|
|
248
|
+
...(opts.as?.callKey !== undefined && { callKey: opts.as.callKey }),
|
|
249
|
+
...(opts.resources !== undefined && { resources: opts.resources }),
|
|
250
|
+
...(opts.signal !== undefined && { signal: opts.signal }),
|
|
251
|
+
...(opts.principal !== undefined && { principal: opts.principal }),
|
|
252
|
+
};
|
|
253
|
+
const pipe = this.deltaPipe(toolId, base, opts.onDelta);
|
|
254
|
+
const ctx: EffectContext = pipe === undefined ? base : { ...base, onDelta: pipe.sink };
|
|
255
|
+
try {
|
|
256
|
+
const out = await this.runSeam<TIn, TOut>(toolId, input, ctx, "ambient", pipe?.onExecute);
|
|
257
|
+
// The terminal chunk goes out only now — after the middleware output phase and
|
|
258
|
+
// the effect's own parse have accepted the result.
|
|
259
|
+
pipe?.finish();
|
|
260
|
+
return out;
|
|
261
|
+
} finally {
|
|
262
|
+
pipe?.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* ADR 0119 — the per-call delta pipe between a streaming effect and the caller's
|
|
268
|
+
* sink. It owns every rule that makes a live tail safe:
|
|
269
|
+
* - REFUSED (undefined) when any middleware `inspectsOutput` — streaming would
|
|
270
|
+
* deliver text past a check that cannot see it; the call runs non-streamed.
|
|
271
|
+
* - A reasoning delta (ADR 0133 D6) passes its own `deltaGuard` instances the same way and
|
|
272
|
+
* reaches the per-call sink and the effect's configured REASONING sink, never its text
|
|
273
|
+
* `deltaSink`.
|
|
274
|
+
* - Each text delta passes the middleware `deltaGuard`s FIRST; a guard that throws
|
|
275
|
+
* severs the stream and the offending delta is never delivered.
|
|
276
|
+
* - Text is delivered as untrusted (tainted) content.
|
|
277
|
+
* - Silent once the call settled or the call's signal aborted (an effect that
|
|
278
|
+
* ignores the signal cannot reach a caller who moved on).
|
|
279
|
+
* - A retry (the effect re-executed) resets guards and sends a `reset` chunk.
|
|
280
|
+
* - The effect's own `done` chunk is held; the terminal chunk is sent by
|
|
281
|
+
* {@link finish} after the call SUCCEEDED.
|
|
282
|
+
* - The caller's sink throwing never fails the call (warned).
|
|
283
|
+
*/
|
|
284
|
+
private deltaPipe(
|
|
285
|
+
toolId: string,
|
|
286
|
+
base: EffectContext,
|
|
287
|
+
callerSink: DeltaSink | undefined
|
|
288
|
+
):
|
|
289
|
+
| {
|
|
290
|
+
sink: DeltaSink;
|
|
291
|
+
onExecute: () => void;
|
|
292
|
+
finish: () => void;
|
|
293
|
+
close: () => void;
|
|
294
|
+
}
|
|
295
|
+
| undefined {
|
|
296
|
+
// The effect's own CONFIGURED sink (createLlmEffect/AgentRunner `onDelta`) is reached
|
|
297
|
+
// only through this pipe, so it is governed exactly like the per-call one.
|
|
298
|
+
const configured = this.registry.get(toolId)?.deltaSink;
|
|
299
|
+
// ADR 0133 D6 - the configured REASONING sink, reached only through this pipe as well.
|
|
300
|
+
const configuredReasoning = this.registry.get(toolId)?.reasoningSink;
|
|
301
|
+
if (callerSink === undefined && configured === undefined && configuredReasoning === undefined) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
const uncovered = this.middleware.findIndex((m) => m.inspectsOutput === true);
|
|
305
|
+
if (uncovered >= 0) {
|
|
306
|
+
const mw = this.middleware[uncovered] as EffectMiddleware;
|
|
307
|
+
this.onWarn(
|
|
308
|
+
`invokeTransient("${toolId}"): streaming disabled: ${
|
|
309
|
+
mw.name !== "" ? mw.name : `middleware[${uncovered}]`
|
|
310
|
+
} inspects output without a delta form, so streamed text would bypass it; the call completes non-streamed (no delta sink — per-call or configured — receives anything)`
|
|
311
|
+
);
|
|
312
|
+
// Fail closed: a DEFINED, silent sink — so the effect cannot fall back to calling
|
|
313
|
+
// its configured sink directly.
|
|
314
|
+
return { sink: () => {}, onExecute: () => {}, finish: () => {}, close: () => {} };
|
|
315
|
+
}
|
|
316
|
+
const mwCtx: EffectContext = { ...base, toolId };
|
|
317
|
+
const { onDelta: _own, ...plain } = base;
|
|
318
|
+
const sinkCtx: EffectContext = plain;
|
|
319
|
+
const origin = `effect:${toolId}`;
|
|
320
|
+
const factories = this.middleware.filter((m) => m.deltaGuard !== undefined);
|
|
321
|
+
let settled = false;
|
|
322
|
+
let attempts = 0;
|
|
323
|
+
let guards: Array<(delta: string) => void> = [];
|
|
324
|
+
// ADR 0133 D6 - reasoning gets guard instances of its own: a guard accumulates the running
|
|
325
|
+
// TEXT it checks, and reasoning interleaved into it would corrupt that window.
|
|
326
|
+
let reasoningGuards: Array<(delta: string) => void> = [];
|
|
327
|
+
let sawDone = false;
|
|
328
|
+
let heldUsage: EffectDelta["usage"];
|
|
329
|
+
const dead = (): boolean => settled || base.signal?.aborted === true;
|
|
330
|
+
const deliver = (chunk: EffectDelta): void => {
|
|
331
|
+
if (dead()) return;
|
|
332
|
+
if (callerSink !== undefined) {
|
|
333
|
+
try {
|
|
334
|
+
callerSink(chunk);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
this.onWarn(`invokeTransient("${toolId}"): onDelta sink threw (ignored): ${String(err)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// The configured sink sees text and resets, never the terminal chunk.
|
|
340
|
+
if (configured !== undefined && chunk.done !== true) {
|
|
341
|
+
try {
|
|
342
|
+
configured(chunk, sinkCtx);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
this.onWarn(`effect "${toolId}": configured onDelta threw (ignored): ${String(err)}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
sink: (chunk) => {
|
|
350
|
+
if (dead()) return;
|
|
351
|
+
if (chunk.done === true) {
|
|
352
|
+
sawDone = true;
|
|
353
|
+
heldUsage = chunk.usage;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (chunk.reasoning !== undefined) {
|
|
357
|
+
const r: TaintedValue<string> = isTainted(chunk.reasoning)
|
|
358
|
+
? chunk.reasoning
|
|
359
|
+
: tainted(chunk.reasoning as unknown as string, `${origin}:reasoning`);
|
|
360
|
+
// May THROW - the sever, exactly as for text. Runs before delivery.
|
|
361
|
+
const rtext = unwrapTainted(r);
|
|
362
|
+
for (const g of reasoningGuards) g(rtext);
|
|
363
|
+
if (callerSink !== undefined && !dead()) {
|
|
364
|
+
try {
|
|
365
|
+
callerSink({ delta: tainted("", origin), reasoning: r });
|
|
366
|
+
} catch (err) {
|
|
367
|
+
this.onWarn(
|
|
368
|
+
`invokeTransient("${toolId}"): onDelta sink threw (ignored): ${String(err)}`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (configuredReasoning !== undefined && !dead()) {
|
|
373
|
+
try {
|
|
374
|
+
configuredReasoning(r, sinkCtx);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
this.onWarn(
|
|
377
|
+
`effect "${toolId}": configured reasoning sink threw (ignored): ${String(err)}`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// An effect that hands over a bare string (a JS caller) still delivers tainted.
|
|
384
|
+
const wrapped: TaintedValue<string> = isTainted(chunk.delta)
|
|
385
|
+
? chunk.delta
|
|
386
|
+
: tainted(chunk.delta as unknown as string, origin);
|
|
387
|
+
const text = unwrapTainted(wrapped);
|
|
388
|
+
// May THROW — the sever. Runs before delivery, so a breaching delta is never seen.
|
|
389
|
+
for (const g of guards) g(text);
|
|
390
|
+
deliver({ delta: wrapped });
|
|
391
|
+
},
|
|
392
|
+
onExecute: () => {
|
|
393
|
+
attempts += 1;
|
|
394
|
+
guards = factories.map((m) => (m.deltaGuard as NonNullable<typeof m.deltaGuard>)(mwCtx));
|
|
395
|
+
reasoningGuards = factories.map((m) =>
|
|
396
|
+
(m.deltaGuard as NonNullable<typeof m.deltaGuard>)(mwCtx)
|
|
397
|
+
);
|
|
398
|
+
sawDone = false;
|
|
399
|
+
heldUsage = undefined;
|
|
400
|
+
if (attempts > 1) deliver({ delta: tainted("", origin), reset: true, attempt: attempts });
|
|
401
|
+
},
|
|
402
|
+
finish: () => {
|
|
403
|
+
if (!sawDone) return;
|
|
404
|
+
deliver({
|
|
405
|
+
delta: tainted("", origin),
|
|
406
|
+
done: true,
|
|
407
|
+
...(heldUsage !== undefined && { usage: heldUsage }),
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
close: () => {
|
|
411
|
+
settled = true;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
has(toolId: string): boolean {
|
|
417
|
+
return this.registry.has(toolId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The registered effects, in registration order (read-only; for a composition root's boot-time checks). */
|
|
421
|
+
entries(): IterableIterator<[string, Effect]> {
|
|
422
|
+
return this.registry.entries();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Advisory pre-warm for a capability, ahead of when it's needed (latency-only,
|
|
427
|
+
* off the durable path). Spins up the registered effect's own cold resource
|
|
428
|
+
* (`Effect.warm` — e.g. a K7 child process or an LLM connection) AND fires the
|
|
429
|
+
* configured {@link Warmer} seam. Never throws: an unregistered toolId is a
|
|
430
|
+
* no-op and a warm failure is swallowed (the real call just runs cold). Does
|
|
431
|
+
* NOT authorize, meter, emit, or touch the fold — warming a capability is not
|
|
432
|
+
* invoking it (P10).
|
|
433
|
+
*/
|
|
434
|
+
warm(toolId: string): void {
|
|
435
|
+
const effect = this.registry.get(toolId);
|
|
436
|
+
if (effect?.warm !== undefined) {
|
|
437
|
+
try {
|
|
438
|
+
void Promise.resolve(effect.warm()).catch(() => {});
|
|
439
|
+
} catch {
|
|
440
|
+
// advisory — a synchronous warm failure is a silent no-op
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.warmer?.warm(toolId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* K9.3 — a registered effect's declared delivery semantic
|
|
448
|
+
* (`new ToolEffect(fn, { idempotent })` / `tool$({ idempotent })`). `false`
|
|
449
|
+
* means recovery must not silently re-run an attempt in an unknown state;
|
|
450
|
+
* `undefined` (undeclared, or no such effect) keeps the legacy at-least-once
|
|
451
|
+
* re-run. Pure/synchronous so the recovery decision is deterministic.
|
|
452
|
+
*/
|
|
453
|
+
declaredIdempotence(toolId: string): boolean | undefined {
|
|
454
|
+
return this.registry.get(toolId)?.idempotent;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* DE-4 — a registered effect's declared failure channels (`Effect.errors`).
|
|
459
|
+
* The kernel's registry fallback when the invoking step declares no `errors:`
|
|
460
|
+
* map of its own; `undefined` (undeclared, or no such effect) means every
|
|
461
|
+
* TypedFailure from this effect degrades to the legacy untyped failure. Pure/
|
|
462
|
+
* synchronous so the replay decision is deterministic.
|
|
463
|
+
*/
|
|
464
|
+
declaredErrors(toolId: string): Record<string, ErrorChannelSchema> | undefined {
|
|
465
|
+
return this.registry.get(toolId)?.errors;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A registered effect's declared determinism-boundary mode (ADR 0073), for tooling —
|
|
470
|
+
* `@loomwork/verify` reads it to flag a `journaled` effect reached from a `ctx.decide`
|
|
471
|
+
* scope, which the transient seam refuses. `undefined` when undeclared or unregistered.
|
|
472
|
+
*/
|
|
473
|
+
modeOf(toolId: string): Effect["mode"] | undefined {
|
|
474
|
+
return this.registry.get(toolId)?.mode;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The registered effect's kind, for tooling: `tool` / `llm` / `human` / `a2a` or any custom
|
|
479
|
+
* name (ADR 0115). Only `"human"` has kernel semantics (it parks).
|
|
480
|
+
*/
|
|
481
|
+
kindOf(toolId: string): Effect["kind"] | undefined {
|
|
482
|
+
return this.registry.get(toolId)?.kind;
|
|
483
|
+
}
|
|
484
|
+
}
|