@openplan/dsh-fuse 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.
@@ -0,0 +1,773 @@
1
+ /**
2
+ * The harness integration — the Cordis wiring that makes the pure modules
3
+ * live against the DeepSeek Harness runtime.
4
+ *
5
+ * Types are the REAL `@deepseek-ai/*` packages (exact-rc devDeps): the
6
+ * waterfall signatures, `PreStepDecision`, `LlmCallConfig` and `SessionEvent`
7
+ * come from the installed declarations, never from local re-declarations.
8
+ *
9
+ * ## Wiring, and the dispatch contracts each one obeys
10
+ *
11
+ * - `session/event` → **meter**. This event is `@mode emit`: a synchronous,
12
+ * fire-and-forget broadcast ("returned promises and values are not awaited
13
+ * or collected") emitted post-commit. An `async` listener therefore leaves a
14
+ * floating promise that a one-shot headless run can exit before it lands.
15
+ * The handler is consequently SYNCHRONOUS and only enqueues; a single shared
16
+ * drain writes to libsql, and the plugin flushes it on `turn/end`,
17
+ * `session/disposed`, and its own disposal — the same non-blocking-enqueue +
18
+ * explicit-drain contract the harness's own telemetry seam documents for
19
+ * this exact hot path.
20
+ * - `agent/pre-step` → **fuse**. `@mode waterfall`; returning
21
+ * `{ kind: 'reject' }` without calling `next()` short-circuits the step
22
+ * before any token is spent.
23
+ * - `agent/request` → **router**. `@mode waterfall`; always calls `next()` and
24
+ * rewrites the returned config, never the messages.
25
+ * - interval → **sync + policy pull**, registered through `ctx.effect()` so
26
+ * every resource Cordis does not manage itself is released on unload, hot
27
+ * reload, config edit, or loss of a required service.
28
+ *
29
+ * The plugin declares **no `inject`**: `ctx.logger` is framework surface, not
30
+ * an injectable service, and `tokenMeter` / `llm` are optional — a hard
31
+ * dependency on an optional service would leave the fiber PENDING forever,
32
+ * silently enforcing nothing.
33
+ */
34
+ import { createBudgetStatusTool, } from "./budget-tool.js";
35
+ import { assertUsableConfig } from "./config.js";
36
+ import { fuseDecision } from "./fuse.js";
37
+ import { hashSessionId, projectCall } from "./meter.js";
38
+ import { createPricingCache, estimateCostUsd, fetchPricingTable, } from "./pricing.js";
39
+ import { routeDecision } from "./router.js";
40
+ import { createLocalStore } from "./store.js";
41
+ import { fetchPolicy, syncBatch } from "./sync.js";
42
+ /** Fixed-density fallback estimate (chars/4) when no token meter is mounted. */
43
+ function estimateTokens(text) {
44
+ return Math.ceil(text.length / 4);
45
+ }
46
+ /** Estimated response size used to price a step before it runs. */
47
+ const DEFAULT_OUTPUT_TOKENS = 512;
48
+ function windowStart(now, window) {
49
+ const start = new Date(now);
50
+ if (window === "month")
51
+ start.setUTCDate(1);
52
+ start.setUTCHours(0, 0, 0, 0);
53
+ return start.toISOString();
54
+ }
55
+ /** Window reset fallback when a 429 arrives without a reset_at. */
56
+ function windowReset(now, window) {
57
+ const reset = new Date(now);
58
+ if (window === "month")
59
+ reset.setUTCMonth(reset.getUTCMonth() + 1, 1);
60
+ else
61
+ reset.setUTCDate(reset.getUTCDate() + 1);
62
+ reset.setUTCHours(0, 0, 0, 0);
63
+ return reset.toISOString();
64
+ }
65
+ /**
66
+ * The plugin entry points.
67
+ *
68
+ * Exported as NAMED functions (the shape the harness's own tutorials use) and
69
+ * re-assembled into the object form by `index.ts`, which is what the loader
70
+ * needs: it unwraps a module to `exports.default` when one exists
71
+ * (`Loader.unwrapExports`) and then reads `plugin.Config` to validate the row's
72
+ * config. A bare-function default export therefore arrives with `Config`
73
+ * undefined — the schema is skipped, defaults are never applied, and `apply`
74
+ * receives partial config. That failure is silent at the type level and only
75
+ * shows up against a real profile, which is why the smoke test boots one.
76
+ */
77
+ export function apply(ctx, config) {
78
+ // Refuse configuration the plugin cannot act on (see config.ts) — Cordis
79
+ // turns the throw into a FAILED fiber, which is the documented outcome.
80
+ assertUsableConfig(config);
81
+ const store = createLocalStore(config.storeUrl);
82
+ const project = config.project;
83
+ const dev = config.dev;
84
+ /** Per-session latest request header (model/provider/reasoning effort). */
85
+ const headers = new Map();
86
+ /** Per `sessionId:turn:step` step-open time, for the latency measure. */
87
+ const stepStarts = new Map();
88
+ /** Tool NAMES per step (metrics only — never arguments). */
89
+ const stepTools = new Map();
90
+ /** Session id → sha256, computed once per session. */
91
+ const sessionHashes = new Map();
92
+ /** Models already reported as unpriced — one warning each, not per call. */
93
+ const unpricedReported = new Set();
94
+ /** Routes whose reasoning set could not be resolved — warned once. */
95
+ const unrankableReported = new Set();
96
+ /** Reasoning-effort ids per route, resolved from the harness lazily. */
97
+ const routeEfforts = new Map();
98
+ const logger = ctx.logger;
99
+ const tokenMeter = () => ctx.get?.("tokenMeter") ?? undefined;
100
+ const llm = () => ctx.get?.("llm") ?? undefined;
101
+ /**
102
+ * Documented load-time rule: *"A plugin should also reject schema-valid
103
+ * config that names an unavailable resource or provider as soon as it can
104
+ * resolve that reference."*
105
+ *
106
+ * Enforced for what this plugin can actually know: when the `llm` service
107
+ * IS mounted, any provider prefix named by `cascade` / `allowedModels` must
108
+ * be a registered route. A model id is NOT rejected for being absent from a
109
+ * catalog — the docs are explicit that catalog membership is advisory.
110
+ *
111
+ * Runs only when `llm` is present: a hard `inject` on an optional service
112
+ * would leave the fiber PENDING forever, silently enforcing nothing.
113
+ */
114
+ function assertProvidersResolvable() {
115
+ const runtime = llm();
116
+ if (!runtime)
117
+ return;
118
+ let registered;
119
+ try {
120
+ registered = runtime.listProviders();
121
+ }
122
+ catch {
123
+ return; // topology unavailable — nothing to verify against
124
+ }
125
+ const known = new Set(registered.map((provider) => provider.id));
126
+ if (known.size === 0)
127
+ return;
128
+ const named = [...config.cascade, ...config.policies.allowedModels];
129
+ const missing = new Set();
130
+ for (const model of named) {
131
+ const prefix = model.split("/")[0];
132
+ // Only provider-prefixed ids name a route; a bare id does not.
133
+ if (!prefix || prefix === model)
134
+ continue;
135
+ if (!known.has(prefix))
136
+ missing.add(prefix);
137
+ }
138
+ if (missing.size > 0) {
139
+ throw new Error(`configuration names provider(s) with no registered route: ${[...missing].join(", ")}. Registered providers: ${[...known].join(", ")}.`);
140
+ }
141
+ }
142
+ assertProvidersResolvable();
143
+ // ── Pricing: config table + optional registry/gateway sync ─────────────
144
+ // Seeded from whatever the last successful fetch persisted, so enforcement
145
+ // is offline-capable from the first boot; the live fetch upgrades it and
146
+ // repersists. The generic resolver keys by `provider/model` first, then by
147
+ // the modal price across every provider that publishes the model.
148
+ const pricingCache = createPricingCache(() => fetchPricingTable({
149
+ registryUrl: config.pricingRegistryUrl,
150
+ gatewayUrl: config.pricingGatewayUrl || undefined,
151
+ gatewayProvider: config.pricingGatewayProvider || undefined,
152
+ gatewayApiKeyEnv: config.pricingGatewayApiKeyEnv || undefined,
153
+ override: config.pricingTable,
154
+ }), 3_600_000, (table) => void store.setPricingTable(table));
155
+ void store.pricingTable().then((persisted) => {
156
+ if (persisted)
157
+ pricingCache.hydrate(persisted);
158
+ if (config.pricingRegistryUrl || config.pricingGatewayUrl) {
159
+ pricingCache.refresh();
160
+ }
161
+ });
162
+ const pricingTableNow = () => ({
163
+ ...pricingCache.current(),
164
+ ...config.pricingTable,
165
+ });
166
+ /**
167
+ * Price one call. An unresolvable model is reported once per model and
168
+ * prices at zero for the row (tokens are still real and still stored) —
169
+ * never silently: an unpriced call is flagged on the wire and, when the
170
+ * deployment set one, covered by the single org fallback rate.
171
+ */
172
+ function priceFor(model, provider, counts) {
173
+ const priced = estimateCostUsd(pricingTableNow(), model, counts, {
174
+ provider,
175
+ aliases: config.pricingAliases,
176
+ });
177
+ if (priced)
178
+ return { costUsd: priced.costUsd, unpriced: false };
179
+ if (model && !unpricedReported.has(model)) {
180
+ unpricedReported.add(model);
181
+ logger.warn(`[dsh] no price for model "%s" on route "%s" — call metered but priced at zero (unpriced); set pricingAliases/pricingTable or an unpricedFallback`, model, provider);
182
+ }
183
+ if (config.unpricedFallback &&
184
+ typeof config.unpricedFallback.inputCentsPerM === "number" &&
185
+ typeof config.unpricedFallback.outputCentsPerM === "number") {
186
+ const fb = config.unpricedFallback;
187
+ const cents = counts.inputTokens * fb.inputCentsPerM +
188
+ counts.outputTokens * fb.outputCentsPerM +
189
+ counts.cacheReadTokens * (fb.cacheReadCentsPerM ?? fb.inputCentsPerM) +
190
+ (counts.cacheWriteTokens ?? 0) *
191
+ (fb.cacheWriteCentsPerM ?? fb.inputCentsPerM);
192
+ return { costUsd: cents / 1e6 / 100, unpriced: true };
193
+ }
194
+ return { costUsd: 0, unpriced: true };
195
+ }
196
+ // ── Meter: enqueue synchronously, drain off the hot path ───────────────
197
+ let queue = [];
198
+ let draining = null;
199
+ async function drainLoop() {
200
+ while (queue.length > 0) {
201
+ const batch = queue;
202
+ queue = [];
203
+ for (const row of batch) {
204
+ try {
205
+ await store.record({
206
+ sessionId: await row.sessionHash,
207
+ project,
208
+ costUsd: row.costUsd,
209
+ at: row.at,
210
+ model: row.model,
211
+ provider: row.provider,
212
+ reasoningEffort: row.reasoningEffort,
213
+ inputTokens: row.counts.inputTokens,
214
+ outputTokens: row.counts.outputTokens,
215
+ cacheReadTokens: row.counts.cacheReadTokens,
216
+ cacheWriteTokens: row.counts.cacheWriteTokens,
217
+ durationMs: row.durationMs,
218
+ unpriced: row.unpriced,
219
+ eventId: row.eventId,
220
+ tools: row.tools,
221
+ });
222
+ }
223
+ catch (err) {
224
+ logger.warn("[dsh] meter write failed", { err: String(err) });
225
+ }
226
+ }
227
+ }
228
+ }
229
+ function scheduleDrain() {
230
+ if (draining)
231
+ return;
232
+ draining = drainLoop().finally(() => {
233
+ draining = null;
234
+ });
235
+ }
236
+ /** Settle once every enqueued row has been handed to the store. */
237
+ async function flushMeter() {
238
+ for (;;) {
239
+ if (!draining && queue.length > 0)
240
+ scheduleDrain();
241
+ if (!draining)
242
+ return;
243
+ await draining;
244
+ }
245
+ }
246
+ function sessionHash(sessionId) {
247
+ let cached = sessionHashes.get(sessionId);
248
+ if (!cached) {
249
+ cached = hashSessionId(sessionId);
250
+ sessionHashes.set(sessionId, cached);
251
+ }
252
+ return cached;
253
+ }
254
+ // ── Firehose ───────────────────────────────────────────────────────────
255
+ ctx.on("session/event", (session, event) => {
256
+ const sessionId = session.id;
257
+ switch (event.type) {
258
+ case "step/start": {
259
+ stepStarts.set(`${sessionId}:${event.data.turn}:${event.data.step}`, event.time);
260
+ stepTools.set(`${sessionId}:${event.data.turn}:${event.data.step}`, []);
261
+ return;
262
+ }
263
+ case "tool/call": {
264
+ // Metrics only: the tool NAME, never the arguments.
265
+ const key = `${sessionId}:${event.data.turn}:${event.data.step}`;
266
+ const tools = stepTools.get(key);
267
+ if (tools && !tools.includes(event.data.name))
268
+ tools.push(event.data.name);
269
+ return;
270
+ }
271
+ case "request/header": {
272
+ const call = event.data.header.config;
273
+ headers.set(sessionId, {
274
+ provider: call.provider,
275
+ model: call.model,
276
+ reasoningEffort: call.reasoningEffort,
277
+ });
278
+ return;
279
+ }
280
+ case "assistant/message": {
281
+ if (!event.data.usage)
282
+ return;
283
+ // A request succeeded — the next request of this turn is a fresh
284
+ // attempt, not a retry, so the escalation counter clears.
285
+ retryCounts.delete(retryKey(sessionId, event.data.turn));
286
+ const header = headers.get(sessionId);
287
+ // `AssistantMessage.source` IS `ModelMessageSource` (the harness
288
+ // types an assistant message's provenance directly), so the model
289
+ // and provider that served this call need no lookup and no cast.
290
+ const source = event.data.message.source;
291
+ const stepKey = `${sessionId}:${event.data.turn}:${event.data.step}`;
292
+ // Tool names observed during this step — consumed here, then
293
+ // dropped so a long session's map cannot grow unbounded.
294
+ const tools = stepTools.get(stepKey) ?? [];
295
+ stepTools.delete(stepKey);
296
+ const projection = projectCall({
297
+ provenance: { provider: source.provider, model: source.model },
298
+ header,
299
+ usage: event.data.usage,
300
+ settledAt: event.time,
301
+ stepStartedAt: stepStarts.get(stepKey),
302
+ });
303
+ stepStarts.delete(stepKey);
304
+ const priced = priceFor(projection.model, projection.provider, projection.counts);
305
+ queue.push({
306
+ sessionHash: sessionHash(sessionId),
307
+ at: new Date(event.time).toISOString(),
308
+ model: projection.model,
309
+ provider: projection.provider,
310
+ reasoningEffort: projection.reasoningEffort,
311
+ counts: projection.counts,
312
+ costUsd: priced.costUsd,
313
+ unpriced: priced.unpriced,
314
+ eventId: crypto.randomUUID(),
315
+ tools,
316
+ durationMs: projection.durationMs,
317
+ });
318
+ scheduleDrain();
319
+ logger.info("[dsh] metered", {
320
+ model: projection.model,
321
+ costUsd: priced.costUsd,
322
+ unpriced: priced.unpriced,
323
+ });
324
+ return;
325
+ }
326
+ case "turn/end": {
327
+ for (const key of stepStarts.keys()) {
328
+ if (key.startsWith(`${sessionId}:`))
329
+ stepStarts.delete(key);
330
+ }
331
+ for (const key of retryCounts.keys()) {
332
+ if (key.startsWith(`${sessionId}\u0000`))
333
+ retryCounts.delete(key);
334
+ }
335
+ void flushMeter();
336
+ return;
337
+ }
338
+ default:
339
+ return;
340
+ }
341
+ });
342
+ ctx.on("session/disposed", (session) => {
343
+ headers.delete(session.id);
344
+ sessionHashes.delete(session.id);
345
+ void flushMeter();
346
+ });
347
+ // ── Policy: the panel publishes it, the local fuse enforces it ─────────
348
+ /**
349
+ * Budgets/policies in force for this call. The SaaS-published policy wins
350
+ * per field when present (the panel is the control plane); the deployment's
351
+ * `cordis.yml` values are the offline fallback, so enforcement never
352
+ * depends on the network being reachable at boot.
353
+ */
354
+ async function effectivePolicy() {
355
+ const remote = await store.remotePolicy();
356
+ const now = new Date();
357
+ const limits = remote?.budgets?.length ? remote.budgets : config.budgets;
358
+ /**
359
+ * Which budgets govern THIS call. A published budget applies only to
360
+ * the calls in its scope, never globally:
361
+ * - `org` → every call on this machine; spend is this machine's total.
362
+ * - `project` → only calls whose project label matches `reference`; spend
363
+ * is this project's.
364
+ * - `dev` → only calls whose dev matches `reference`; spend is this
365
+ * dev's.
366
+ * A local `config.budgets` entry has no scope and applies to this
367
+ * project on this machine (the offline fallback's meaning).
368
+ */
369
+ const budgets = [];
370
+ for (const budget of limits) {
371
+ const scope = budget.scope;
372
+ if (scope === "project") {
373
+ const reference = budget.reference;
374
+ // Absent reference = legacy publication: apply to this project.
375
+ if (reference !== undefined && reference !== project)
376
+ continue;
377
+ }
378
+ if (scope === "dev") {
379
+ const reference = budget.reference;
380
+ if (reference !== undefined && reference !== dev)
381
+ continue;
382
+ }
383
+ const spentUsd = scope === "project"
384
+ ? await store.spentForWindow({
385
+ project,
386
+ since: windowStart(now, budget.window),
387
+ })
388
+ : scope === "dev"
389
+ ? await store.spentForWindow({
390
+ dev,
391
+ since: windowStart(now, budget.window),
392
+ })
393
+ : await store.spentForWindow({
394
+ since: windowStart(now, budget.window),
395
+ });
396
+ budgets.push({
397
+ limitUsd: budget.limitUsd,
398
+ window: budget.window,
399
+ spentUsd,
400
+ });
401
+ }
402
+ return {
403
+ budgets,
404
+ // The SaaS-published value wins per field when it is present; the
405
+ // config's empty encodings ("no cap", "all allowed", "none denied")
406
+ // translate back to absent so the fuse treats them as unrestricted.
407
+ policies: {
408
+ maxReasoningEffort: remote?.maxReasoningEffort ||
409
+ config.policies.maxReasoningEffort ||
410
+ undefined,
411
+ allowedModels: remote?.allowedModels?.length
412
+ ? remote.allowedModels
413
+ : config.policies.allowedModels.length > 0
414
+ ? config.policies.allowedModels
415
+ : undefined,
416
+ denylistedProjects: remote?.denylistedProjects?.length
417
+ ? remote.denylistedProjects
418
+ : config.policies.denylistedProjects.length > 0
419
+ ? config.policies.denylistedProjects
420
+ : undefined,
421
+ },
422
+ };
423
+ }
424
+ // ── Model-facing status: the documented "separate model-facing ask tool" ─
425
+ /**
426
+ * The model-facing view of the policy state — same scope filtering as
427
+ * `effectivePolicy`, so the numbers the agent reads are exactly the numbers
428
+ * the fuse enforces. A local `config.budgets` entry (no scope) is the
429
+ * offline fallback and reads as an org-level budget on this machine.
430
+ */
431
+ async function readBudgetStatus() {
432
+ const now = new Date();
433
+ const remote = await store.remotePolicy();
434
+ const limits = remote?.budgets?.length ? remote.budgets : config.budgets;
435
+ const budgets = [];
436
+ for (const budget of limits) {
437
+ const scope = budget.scope ?? "org";
438
+ const reference = budget.reference;
439
+ if (scope === "project" &&
440
+ reference !== undefined &&
441
+ reference !== project)
442
+ continue;
443
+ if (scope === "dev" && reference !== undefined && reference !== dev)
444
+ continue;
445
+ const spentUsd = scope === "project"
446
+ ? await store.spentForWindow({
447
+ project,
448
+ since: windowStart(now, budget.window),
449
+ })
450
+ : scope === "dev"
451
+ ? await store.spentForWindow({
452
+ dev,
453
+ since: windowStart(now, budget.window),
454
+ })
455
+ : await store.spentForWindow({
456
+ since: windowStart(now, budget.window),
457
+ });
458
+ budgets.push({
459
+ scope,
460
+ ...(reference !== undefined ? { reference } : {}),
461
+ window: budget.window,
462
+ limitUsd: budget.limitUsd,
463
+ spentUsd,
464
+ });
465
+ }
466
+ const block = await store.remoteBlockFor({ project, dev });
467
+ return {
468
+ project,
469
+ dev,
470
+ at: now.toISOString(),
471
+ budgets,
472
+ block,
473
+ };
474
+ }
475
+ /**
476
+ * Register `dsh_budget_status` for the agent when the composition mounts
477
+ * the `tools` service (`@deepseek-ai/dsh-tools`). Registration is optional
478
+ * by design — the plugin declares no `inject`, so an absent service skips
479
+ * the surface instead of holding the fiber PENDING. Status-only: the tool
480
+ * reads state and never decides.
481
+ */
482
+ const toolsRuntime = ctx.get?.("tools");
483
+ if (config.budgetStatusTool && toolsRuntime) {
484
+ ctx.effect(() => toolsRuntime.register(createBudgetStatusTool(readBudgetStatus)));
485
+ }
486
+ /**
487
+ * The route's ordered reasoning-effort ids, from the harness itself: the
488
+ * cap is ranked by index in the ADAPTER's own set, never by a local table.
489
+ * Cached per route; `undefined` when no llm service or no metadata, which
490
+ * the fuse reports as "not enforced" instead of guessing.
491
+ */
492
+ async function reasoningEffortsFor(provider, model) {
493
+ const runtime = llm();
494
+ if (!runtime || !provider || !model)
495
+ return undefined;
496
+ const key = `${provider}\u0000${model}`;
497
+ const cached = routeEfforts.get(key);
498
+ if (cached)
499
+ return cached;
500
+ try {
501
+ const info = await runtime.resolveModelInfo(provider, model);
502
+ const ids = (info.reasoning?.efforts ?? []).map((effort) => String(effort.id));
503
+ if (ids.length > 0) {
504
+ routeEfforts.set(key, ids);
505
+ return ids;
506
+ }
507
+ }
508
+ catch {
509
+ // The route publishes no reasoning metadata — reported below.
510
+ }
511
+ return undefined;
512
+ }
513
+ // ── Fuse: the primary gate, before any token is spent ──────────────────
514
+ ctx.on("agent/pre-step", async (payload, next) => {
515
+ const agentId = payload.agent.id;
516
+ const header = headers.get(agentId);
517
+ const model = header?.model ||
518
+ payload.agent.options?.model ||
519
+ "";
520
+ const provider = header?.provider ||
521
+ payload.agent.options
522
+ ?.provider ||
523
+ "";
524
+ // Sync fidelity: a SaaS 429 engages the offline fuse until the
525
+ // window resets — the team blocked centrally is blocked locally.
526
+ // Blocks are scoped: a project budget 429 freezes only that
527
+ // project on this machine (org blocks freeze everything).
528
+ const remoteBlock = await store.remoteBlockFor({ project, dev });
529
+ if (remoteBlock) {
530
+ logger.warn("[dsh] fuse blocked (remote 429 active)", {
531
+ rule: remoteBlock.rule,
532
+ resetAt: remoteBlock.resetAt,
533
+ project,
534
+ });
535
+ return { kind: "reject" };
536
+ }
537
+ const estimatedCostUsd = estimateStepCostUsd(payload.agent, payload.messages, model, provider);
538
+ const { budgets, policies } = await effectivePolicy();
539
+ const efforts = policies.maxReasoningEffort
540
+ ? await reasoningEffortsFor(provider, model)
541
+ : undefined;
542
+ const decision = fuseDecision({
543
+ project,
544
+ model,
545
+ reasoningEffort: header?.reasoningEffort,
546
+ estimatedCostUsd,
547
+ budgets,
548
+ policies,
549
+ now: new Date(),
550
+ reasoningEfforts: efforts,
551
+ });
552
+ for (const gap of decision.notEnforced) {
553
+ const key = `${gap}\u0000${provider}/${model}`;
554
+ if (unrankableReported.has(key))
555
+ continue;
556
+ unrankableReported.add(key);
557
+ logger.warn("[dsh] policy not enforceable on this route (%s): the adapter publishes no matching reasoning-effort id, so the cap was not applied", gap);
558
+ }
559
+ if (!decision.allowed) {
560
+ await store.recordCut({
561
+ project,
562
+ rule: decision.rule ?? "unknown",
563
+ });
564
+ logger.warn("[dsh] fuse blocked", {
565
+ rule: decision.rule,
566
+ project,
567
+ });
568
+ return { kind: "reject" };
569
+ }
570
+ return next();
571
+ });
572
+ /**
573
+ * Price the step before it runs. Prefers the harness's own replay-aware
574
+ * token meter (`ctx.tokenMeter.measure`, the documented request-pressure
575
+ * snapshot) and falls back to the fixed chars/4 heuristic only when no
576
+ * meter is mounted, so the estimate does not drift from the harness's own
577
+ * accounting on a full composition.
578
+ *
579
+ * Uses the meter's `surfaceTokens` — the surface-only route-priced total —
580
+ * as the input estimate, never `totalTokens`, because the docs define
581
+ * `totalTokens` as *request-and-response* pressure; adding
582
+ * `DEFAULT_OUTPUT_TOKENS` on top of it would count the response twice and
583
+ * manufacture false-positive cuts.
584
+ */
585
+ function estimateStepCostUsd(agent, messages, model, provider) {
586
+ let inputTokens = null;
587
+ const meter = tokenMeter();
588
+ if (meter) {
589
+ try {
590
+ const measurement = meter.measure(agent.session);
591
+ inputTokens = measurement.surfaceTokens;
592
+ }
593
+ catch {
594
+ inputTokens = null;
595
+ }
596
+ }
597
+ if (inputTokens === null) {
598
+ inputTokens = estimateTokens(messages.map((message) => JSON.stringify(message)).join("\n"));
599
+ }
600
+ return priceFor(model, provider, {
601
+ inputTokens,
602
+ outputTokens: DEFAULT_OUTPUT_TOKENS,
603
+ cacheReadTokens: 0,
604
+ cacheWriteTokens: 0,
605
+ }).costUsd;
606
+ }
607
+ // ── Router: policy-compliant model rewrite ─────────────────────────────
608
+ /**
609
+ * Escalation counter per (agent, turn): bumped on `agent/request-error`
610
+ * (the documented "you own recovery" seam) and cleared when a request
611
+ * succeeds. `payload.step` is NOT an attempt count — it increments once per
612
+ * step of a normal tool-using turn, so using it would walk the cascade to
613
+ * the most expensive model on a routine 5-step turn (the visible quality
614
+ * degradation the proposal rules out). Escalation therefore only happens
615
+ * when the immediately previous request of this turn actually failed.
616
+ */
617
+ const retryCounts = new Map();
618
+ const retryKey = (agentId, turn) => `${agentId}\u0000${turn}`;
619
+ ctx.on("agent/request-error", async (payload, next) => {
620
+ // Observe + delegate: the plugin never owns provider retries.
621
+ const key = retryKey(payload.agent.id, payload.turn);
622
+ retryCounts.set(key, (retryCounts.get(key) ?? 0) + 1);
623
+ return next();
624
+ });
625
+ ctx.on("agent/request", async (payload, next) => {
626
+ const current = await next();
627
+ const attempt = retryCounts.get(retryKey(payload.agent.id, payload.turn)) ?? 0;
628
+ const routed = routeDecision({
629
+ requestedModel: current.model,
630
+ attempt,
631
+ cascade: config.cascade,
632
+ policies: config.policies,
633
+ });
634
+ if (routed.model !== current.model) {
635
+ logger.info("[dsh] routed", {
636
+ from: current.model,
637
+ to: routed.model,
638
+ reason: routed.reason,
639
+ attempt,
640
+ });
641
+ return { ...current, model: routed.model };
642
+ }
643
+ return current;
644
+ });
645
+ // ── SaaS sync + policy pull, released by ctx.effect on unload ──────────
646
+ ctx.effect(() => {
647
+ if (!config.baseUrl || !config.orgKey)
648
+ return () => undefined;
649
+ const target = {
650
+ baseUrl: config.baseUrl,
651
+ orgKey: config.orgKey,
652
+ };
653
+ let syncing = false;
654
+ let refreshing = false;
655
+ let lastPolicyAt = 0;
656
+ async function syncOnce() {
657
+ if (syncing)
658
+ return;
659
+ syncing = true;
660
+ try {
661
+ await flushMeter();
662
+ const pending = await store.pendingSync(500);
663
+ const pendingCuts = await store.pendingCuts(100);
664
+ if (pending.length === 0 && pendingCuts.length === 0)
665
+ return;
666
+ const events = [
667
+ ...pending.map((row) => ({
668
+ v: 1,
669
+ event_id: row.eventId,
670
+ session_id: row.sessionId,
671
+ project: row.project,
672
+ dev,
673
+ provider: row.provider || "unknown",
674
+ model: row.model || "unknown",
675
+ reasoning_effort: row.reasoningEffort || undefined,
676
+ input_tokens: row.inputTokens,
677
+ output_tokens: row.outputTokens,
678
+ cache_read_tokens: row.cacheReadTokens,
679
+ cache_write_tokens: row.cacheWriteTokens,
680
+ duration_ms: row.durationMs ?? undefined,
681
+ cost_usd: row.costUsd,
682
+ ...(row.unpriced ? { unpriced: true } : {}),
683
+ started_at: row.at,
684
+ ...(row.tools.length > 0 ? { tools: row.tools } : {}),
685
+ })),
686
+ ...pendingCuts.map((cut) => ({
687
+ kind: "cut",
688
+ project: cut.project,
689
+ rule: cut.rule,
690
+ })),
691
+ ];
692
+ const result = await syncBatch({ ...target, events });
693
+ if (result.blocked) {
694
+ logger.warn("[dsh] SaaS blocked — engaging the local fuse", {
695
+ rule: result.blockedRule,
696
+ resetAt: result.resetAt,
697
+ scope: result.blockScope,
698
+ reference: result.blockReference,
699
+ });
700
+ await store.setRemoteBlock({
701
+ rule: result.blockedRule ?? "budget_exceeded",
702
+ resetAt: result.resetAt ?? windowReset(new Date(), "day"),
703
+ ...(result.blockScope ? { scope: result.blockScope } : {}),
704
+ ...(result.blockReference
705
+ ? { reference: result.blockReference }
706
+ : {}),
707
+ });
708
+ return;
709
+ }
710
+ if (!result.delivered) {
711
+ // Keep the rows: a failed batch is not a delivered batch.
712
+ logger.warn("[dsh] sync failed — rows retained for retry", {
713
+ status: result.status,
714
+ error: result.error,
715
+ });
716
+ return;
717
+ }
718
+ await store.markSynced(pending.map((row) => row.id));
719
+ await store.markCutsSynced(pendingCuts.map((cut) => cut.id));
720
+ await store.setRemoteBlock(null);
721
+ }
722
+ finally {
723
+ syncing = false;
724
+ }
725
+ }
726
+ async function refreshPolicyOnce() {
727
+ if (refreshing)
728
+ return;
729
+ refreshing = true;
730
+ try {
731
+ const { policy, error } = await fetchPolicy(target);
732
+ if (error) {
733
+ if (error !== "unauthorized") {
734
+ logger.warn("[dsh] policy refresh failed — keeping the last published policy", {
735
+ error,
736
+ });
737
+ }
738
+ return;
739
+ }
740
+ await store.setRemotePolicy(policy);
741
+ lastPolicyAt = Date.now();
742
+ }
743
+ finally {
744
+ refreshing = false;
745
+ }
746
+ }
747
+ // Pull the org policy immediately so a freshly started session enforces
748
+ // panel state rather than whatever the YAML happens to say.
749
+ void refreshPolicyOnce();
750
+ const timer = setInterval(() => {
751
+ void syncOnce().catch((err) => {
752
+ logger.warn("[dsh] sync failed", { err: String(err) });
753
+ });
754
+ if (Date.now() - lastPolicyAt >= config.policyRefreshMs) {
755
+ void refreshPolicyOnce().catch((err) => {
756
+ logger.warn("[dsh] policy refresh failed", { err: String(err) });
757
+ });
758
+ }
759
+ }, config.syncIntervalMs);
760
+ return async () => {
761
+ clearInterval(timer);
762
+ await flushMeter();
763
+ store.close();
764
+ };
765
+ });
766
+ // Local-only mode still owns the store; release it on unload.
767
+ if (!config.baseUrl || !config.orgKey) {
768
+ ctx.effect(() => async () => {
769
+ await flushMeter();
770
+ store.close();
771
+ }, "dsh-meter-flush");
772
+ }
773
+ }