@agentguard-run/spend 0.6.1 → 0.8.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +20 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/outcomes/index.d.ts +6 -0
  7. package/dist/outcomes/index.d.ts.map +1 -0
  8. package/dist/outcomes/index.js +22 -0
  9. package/dist/outcomes/index.js.map +1 -0
  10. package/dist/outcomes/learning.d.ts +19 -0
  11. package/dist/outcomes/learning.d.ts.map +1 -0
  12. package/dist/outcomes/learning.js +104 -0
  13. package/dist/outcomes/learning.js.map +1 -0
  14. package/dist/outcomes/ledger.d.ts +42 -0
  15. package/dist/outcomes/ledger.d.ts.map +1 -0
  16. package/dist/outcomes/ledger.js +110 -0
  17. package/dist/outcomes/ledger.js.map +1 -0
  18. package/dist/outcomes/quality-gate.d.ts +12 -0
  19. package/dist/outcomes/quality-gate.d.ts.map +1 -0
  20. package/dist/outcomes/quality-gate.js +104 -0
  21. package/dist/outcomes/quality-gate.js.map +1 -0
  22. package/dist/outcomes/runtime.d.ts +21 -0
  23. package/dist/outcomes/runtime.d.ts.map +1 -0
  24. package/dist/outcomes/runtime.js +321 -0
  25. package/dist/outcomes/runtime.js.map +1 -0
  26. package/dist/outcomes/types.d.ts +147 -0
  27. package/dist/outcomes/types.d.ts.map +1 -0
  28. package/dist/outcomes/types.js +14 -0
  29. package/dist/outcomes/types.js.map +1 -0
  30. package/dist/router.d.ts +52 -0
  31. package/dist/router.d.ts.map +1 -0
  32. package/dist/router.js +563 -0
  33. package/dist/router.js.map +1 -0
  34. package/dist/spend-guard.d.ts +31 -0
  35. package/dist/spend-guard.d.ts.map +1 -1
  36. package/dist/spend-guard.js +158 -3
  37. package/dist/spend-guard.js.map +1 -1
  38. package/dist/types.d.ts +3 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +14 -2
  41. package/src/outcomes/index.ts +5 -0
  42. package/src/outcomes/learning.ts +133 -0
  43. package/src/outcomes/ledger.ts +131 -0
  44. package/src/outcomes/quality-gate.ts +116 -0
  45. package/src/outcomes/runtime.ts +388 -0
  46. package/src/outcomes/types.ts +167 -0
  47. package/src/router.ts +614 -0
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { buildScopeKey } from '../policy';
3
+ import type { SpendScope, SpendStore, SpendWindow } from '../types';
4
+
5
+ export interface OutcomeSpendHold {
6
+ holdId: string;
7
+ scopeKey: string;
8
+ window: SpendWindow;
9
+ heldCents: number;
10
+ limitCents: number;
11
+ before: number;
12
+ after: number;
13
+ released: boolean;
14
+ }
15
+
16
+ export interface OutcomeSpendLedgerOptions {
17
+ keyPrefix?: string;
18
+ }
19
+
20
+ export class OutcomeSpendLimitError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'OutcomeSpendLimitError';
24
+ }
25
+ }
26
+
27
+ export class OutcomeAtomicLedgerError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'OutcomeAtomicLedgerError';
31
+ }
32
+ }
33
+
34
+ export class OutcomeSpendLedger {
35
+ private readonly keyPrefix: string;
36
+
37
+ constructor(private readonly store: SpendStore, options: OutcomeSpendLedgerOptions = {}) {
38
+ this.keyPrefix = options.keyPrefix ?? 'outcome:v1';
39
+ }
40
+
41
+ async authorize(args: {
42
+ scope: SpendScope;
43
+ window: SpendWindow;
44
+ amountCents: number;
45
+ limitCents: number;
46
+ }): Promise<boolean> {
47
+ validateCents('amountCents', args.amountCents);
48
+ validateCents('limitCents', args.limitCents);
49
+ if (args.window === 'per_call') return args.amountCents <= args.limitCents;
50
+ const current = await this.store.getWindowSpend(this.scopeKey(args.scope), args.window);
51
+ return current + args.amountCents <= args.limitCents;
52
+ }
53
+
54
+ async hold(args: {
55
+ scope: SpendScope;
56
+ window: SpendWindow;
57
+ amountCents: number;
58
+ limitCents: number;
59
+ }): Promise<OutcomeSpendHold> {
60
+ validateCents('amountCents', args.amountCents);
61
+ validateCents('limitCents', args.limitCents);
62
+ const scopeKey = this.scopeKey(args.scope);
63
+ if (args.window === 'per_call') {
64
+ if (args.amountCents > args.limitCents) {
65
+ throw new OutcomeSpendLimitError('Outcome per-call cap would be exceeded before provider dispatch');
66
+ }
67
+ return {
68
+ holdId: randomUUID(),
69
+ scopeKey,
70
+ window: args.window,
71
+ heldCents: args.amountCents,
72
+ limitCents: args.limitCents,
73
+ before: 0,
74
+ after: args.amountCents,
75
+ released: false,
76
+ };
77
+ }
78
+
79
+ if (!this.store.reserveWindowSpend) {
80
+ throw new OutcomeAtomicLedgerError('Outcome runtime requires SpendStore.reserveWindowSpend for atomic cap holds');
81
+ }
82
+ const reservation = await this.store.reserveWindowSpend(scopeKey, args.window, args.amountCents, args.limitCents);
83
+ if (!reservation.allowed) {
84
+ throw new OutcomeSpendLimitError('Outcome period cap would be exceeded before provider dispatch');
85
+ }
86
+ return {
87
+ holdId: randomUUID(),
88
+ scopeKey,
89
+ window: args.window,
90
+ heldCents: args.amountCents,
91
+ limitCents: args.limitCents,
92
+ before: reservation.before,
93
+ after: reservation.after,
94
+ released: false,
95
+ };
96
+ }
97
+
98
+ async settle(hold: OutcomeSpendHold, actualCents: number): Promise<number> {
99
+ validateCents('actualCents', actualCents);
100
+ if (hold.released) return hold.after;
101
+ hold.released = true;
102
+ const refundCents = Math.max(0, hold.heldCents - actualCents);
103
+ if (hold.window === 'per_call') return Math.max(0, hold.heldCents - refundCents);
104
+ if (!this.store.settleWindowSpend) return hold.after;
105
+ if (refundCents === 0) return hold.after;
106
+ return this.store.settleWindowSpend(hold.scopeKey, hold.window, -refundCents);
107
+ }
108
+
109
+ async refund(hold: OutcomeSpendHold): Promise<number> {
110
+ if (hold.released) return hold.after;
111
+ hold.released = true;
112
+ if (hold.window === 'per_call') return 0;
113
+ if (!this.store.settleWindowSpend) return hold.after;
114
+ return this.store.settleWindowSpend(hold.scopeKey, hold.window, -hold.heldCents);
115
+ }
116
+
117
+ dynamicLowBalanceThreshold(limitCents: number): number {
118
+ validateCents('limitCents', limitCents);
119
+ return Math.max(1, Math.min(50, Math.ceil(limitCents * 0.1)));
120
+ }
121
+
122
+ scopeKey(scope: SpendScope): string {
123
+ return this.keyPrefix + ':' + buildScopeKey(scope);
124
+ }
125
+ }
126
+
127
+ function validateCents(name: string, value: number): void {
128
+ if (!Number.isSafeInteger(value) || value < 0) {
129
+ throw new Error(name + ' must be a non-negative safe integer');
130
+ }
131
+ }
@@ -0,0 +1,116 @@
1
+ import type { OutcomeQualityGateConfig, OutcomeTemplate } from './types';
2
+
3
+ export interface OutcomeQualityGateResult {
4
+ score: number;
5
+ riskScore: number;
6
+ passed: boolean;
7
+ issues: string[];
8
+ triggerFired: string[];
9
+ }
10
+
11
+ const DEFAULT_PASS_THRESHOLD = 0.7;
12
+ const DEFAULT_REVIEWER_TRIGGER = 0.55;
13
+ const GENERIC_RISK_TERMS = [
14
+ 'unlimited liability',
15
+ 'indemnification',
16
+ 'non-compete',
17
+ 'material weakness',
18
+ 'coverage denied',
19
+ 'claim denied',
20
+ 'wire instructions',
21
+ 'refund denied',
22
+ 'regulatory filing',
23
+ ];
24
+
25
+ export function scoreOutcomeOutput(
26
+ outputText: string,
27
+ template: OutcomeTemplate,
28
+ providerScore?: number,
29
+ ): OutcomeQualityGateResult {
30
+ const config = template.qualityGate ?? {};
31
+ const issues: string[] = [];
32
+ const text = outputText.trim();
33
+ let score = bounded(providerScore ?? heuristicQualityScore(text, template, issues));
34
+ if (text.length === 0) {
35
+ score = 0;
36
+ issues.push('empty_output');
37
+ }
38
+
39
+ const riskScore = triageRisk(text, config, template.vertical);
40
+ const threshold = config.passThreshold ?? DEFAULT_PASS_THRESHOLD;
41
+ const reviewerThreshold = config.reviewerTriggerScore ?? DEFAULT_REVIEWER_TRIGGER;
42
+ const triggerFired: string[] = [];
43
+ if (riskScore >= reviewerThreshold) triggerFired.push('high_risk_classifier_score_above:' + reviewerThreshold);
44
+ const keywordTriggers = keywordHits(text, [...GENERIC_RISK_TERMS, ...(config.riskKeywords ?? [])]);
45
+ if (keywordTriggers.length) triggerFired.push('keyword_watchlist:' + keywordTriggers.join(','));
46
+ if (score < threshold) triggerFired.push('quality_score_below:' + threshold);
47
+
48
+ return {
49
+ score,
50
+ riskScore,
51
+ passed: score >= threshold,
52
+ issues: [...new Set(issues)],
53
+ triggerFired: [...new Set(triggerFired)],
54
+ };
55
+ }
56
+
57
+ export function shouldRunReviewer(result: OutcomeQualityGateResult): boolean {
58
+ return result.triggerFired.length > 0;
59
+ }
60
+
61
+ export function triageRisk(
62
+ outputText: string,
63
+ config: OutcomeQualityGateConfig = {},
64
+ vertical = 'general',
65
+ ): number {
66
+ const lower = outputText.toLowerCase();
67
+ const terms = [...GENERIC_RISK_TERMS, ...(config.riskKeywords ?? []), ...verticalRiskTerms(vertical)];
68
+ const hits = keywordHits(lower, terms).length;
69
+ const lengthPenalty = outputText.length > 6000 ? 0.08 : 0;
70
+ const disclaimerPenalty = /(legal|tax|insurance) advice/i.test(outputText) ? -0.05 : 0;
71
+ return bounded((hits * 0.18) + lengthPenalty + disclaimerPenalty);
72
+ }
73
+
74
+ function heuristicQualityScore(text: string, template: OutcomeTemplate, issues: string[]): number {
75
+ let score = 0.74;
76
+ if (template.disclaimer && !text.toLowerCase().includes(shortDisclaimerAnchor(template.disclaimer))) {
77
+ issues.push('missing_disclaimer');
78
+ score -= 0.12;
79
+ }
80
+ if (/cite|citation|source|provenance/i.test(template.systemPrompt) && !/(source|citation|provenance|cited)/i.test(text)) {
81
+ issues.push('missing_source_reference');
82
+ score -= 0.12;
83
+ }
84
+ if (template.outputFormat.toLowerCase().includes('json')) {
85
+ try {
86
+ JSON.parse(text);
87
+ } catch {
88
+ issues.push('invalid_json_output');
89
+ score -= 0.2;
90
+ }
91
+ }
92
+ return bounded(score);
93
+ }
94
+
95
+ function shortDisclaimerAnchor(disclaimer: string): string {
96
+ return disclaimer.toLowerCase().split('.').at(0)?.slice(0, 24) ?? '';
97
+ }
98
+
99
+ function keywordHits(text: string, terms: string[]): string[] {
100
+ const lower = text.toLowerCase();
101
+ return terms.filter((term) => term && lower.includes(term.toLowerCase()));
102
+ }
103
+
104
+ function verticalRiskTerms(vertical: string): string[] {
105
+ if (vertical === 'law') return ['conflict of interest', 'privilege waived', 'statute of limitations'];
106
+ if (vertical === 'accounting') return ['material misstatement', 'gaap departure', 'tax penalty'];
107
+ if (vertical === 'insurance') return ['exclusion', 'endorsement', 'coverage gap'];
108
+ if (vertical === 'realestate' || vertical === 'real-estate') return ['fair housing', 'respa', 'disclosure defect'];
109
+ if (vertical === 'ecommerce') return ['chargeback', 'ftc', 'platform violation'];
110
+ return [];
111
+ }
112
+
113
+ function bounded(value: number): number {
114
+ if (!Number.isFinite(value)) return 0;
115
+ return Math.max(0, Math.min(1, value));
116
+ }
@@ -0,0 +1,388 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { buildScopeKey } from '../policy';
3
+ import { computeCallCents, inferProvider } from '../cost-table';
4
+ import { recommend, type ModelRouteRecommendation } from '../router';
5
+ import type { SpendDecision, SignedDecisionLogEntry } from '../types';
6
+ import type { SpendGuard } from '../spend-guard';
7
+ import { OutcomeSpendLedger, type OutcomeSpendHold } from './ledger';
8
+ import { scoreOutcomeOutput, shouldRunReviewer, type OutcomeQualityGateResult } from './quality-gate';
9
+ import {
10
+ InMemoryOutcomeReceiptStore,
11
+ type OutcomeDecisionLink,
12
+ type OutcomeExecutionContext,
13
+ type OutcomeModelRequest,
14
+ type OutcomeModelResponse,
15
+ type OutcomeModelRunner,
16
+ type OutcomeReceiptStore,
17
+ type OutcomeRuntimeReceipt,
18
+ type OutcomeTemplate,
19
+ } from './types';
20
+
21
+ export interface ExecuteOutcomeTaskOptions {
22
+ guard: SpendGuard;
23
+ template: OutcomeTemplate;
24
+ inputs: Record<string, unknown>;
25
+ runner: OutcomeModelRunner;
26
+ context: OutcomeExecutionContext;
27
+ ledger?: OutcomeSpendLedger;
28
+ receiptStore?: OutcomeReceiptStore;
29
+ }
30
+
31
+ export interface ExecuteOutcomeTaskResult {
32
+ receipt: OutcomeRuntimeReceipt;
33
+ signed: SignedDecisionLogEntry | null;
34
+ outputText: string;
35
+ reviewerOutputText?: string;
36
+ }
37
+
38
+ interface ModelRunResult {
39
+ response: OutcomeModelResponse;
40
+ decision: SpendDecision;
41
+ signed: SignedDecisionLogEntry | null;
42
+ actualInputTokens: number;
43
+ actualOutputTokens: number;
44
+ actualCents: number;
45
+ durationMs: number;
46
+ }
47
+
48
+ export async function executeOutcomeTask(options: ExecuteOutcomeTaskOptions): Promise<ExecuteOutcomeTaskResult> {
49
+ validateTemplate(options.template);
50
+ validateInputs(options.template, options.inputs);
51
+ const started = Date.now();
52
+ const receiptStore = options.receiptStore ?? new InMemoryOutcomeReceiptStore();
53
+ const ledger = options.ledger ?? new OutcomeSpendLedger(options.guard.getSpendStore());
54
+ const posture = options.context.posture ?? 'standard';
55
+ const budgetTier = options.context.budgetTier ?? 'solo';
56
+ const route = recommend({
57
+ vertical: options.template.vertical,
58
+ outcome: options.template.slug,
59
+ posture,
60
+ budgetTier,
61
+ imageCapable: options.context.imageCapable,
62
+ });
63
+ const blockedOrigins = [...new Set([...(options.template.blockedOrigins ?? []), ...route.blockedOrigins])];
64
+ const hold = await ledger.hold({
65
+ scope: options.context.scope,
66
+ window: options.template.spendCaps.window ?? 'per_call',
67
+ amountCents: options.template.spendCaps.maxCostCents,
68
+ limitCents: options.template.spendCaps.periodCapCents ?? options.template.spendCaps.maxCostCents,
69
+ });
70
+
71
+ const links: OutcomeDecisionLink[] = [];
72
+ let status: OutcomeRuntimeReceipt['status'] = 'failed';
73
+ let drafter: ModelRunResult | null = null;
74
+ let reviewer: ModelRunResult | null = null;
75
+ let fallback: OutcomeRuntimeReceipt['fallback'] = null;
76
+ let outputText = '';
77
+ let reviewerOutputText: string | undefined;
78
+ let gate: OutcomeQualityGateResult = { score: 0, riskScore: 0, passed: false, issues: ['not_run'], triggerFired: [] };
79
+
80
+ try {
81
+ drafter = await runGuardedModel({
82
+ guard: options.guard,
83
+ template: options.template,
84
+ context: options.context,
85
+ route,
86
+ role: 'drafter',
87
+ model: route.drafter,
88
+ inputs: options.inputs,
89
+ runner: options.runner,
90
+ });
91
+ links.push(linkFor('drafter', drafter));
92
+ outputText = drafter.response.text;
93
+ gate = scoreOutcomeOutput(outputText, options.template, drafter.response.qualityScore);
94
+
95
+ if (shouldRunReviewer(gate) && options.context.reviewerCascadeAuto === true) {
96
+ const reviewerModel = antiCorrelatedReviewer(route, drafter.decision.modelResolved);
97
+ reviewer = await runGuardedModel({
98
+ guard: options.guard,
99
+ template: options.template,
100
+ context: options.context,
101
+ route,
102
+ role: 'reviewer',
103
+ model: reviewerModel,
104
+ inputs: { outcome_metadata_only: true, trigger_fired: gate.triggerFired.join(',') },
105
+ runner: options.runner,
106
+ priorOutputText: outputText,
107
+ });
108
+ links.push(linkFor('reviewer', reviewer));
109
+ reviewerOutputText = reviewer.response.text;
110
+ status = 'completed';
111
+ } else {
112
+ status = gate.passed ? 'completed' : 'quality_failed';
113
+ }
114
+ } catch (err) {
115
+ if (!drafter) {
116
+ const fallbackModel = antiCorrelatedFallback(route, route.drafter);
117
+ if (fallbackModel) {
118
+ fallback = { model: fallbackModel, reason: errorReason(err) };
119
+ try {
120
+ drafter = await runGuardedModel({
121
+ guard: options.guard,
122
+ template: options.template,
123
+ context: options.context,
124
+ route,
125
+ role: 'fallback',
126
+ model: fallbackModel,
127
+ inputs: options.inputs,
128
+ runner: options.runner,
129
+ });
130
+ fallback.decisionId = drafter.decision.decisionId;
131
+ links.push(linkFor('fallback', drafter));
132
+ outputText = drafter.response.text;
133
+ gate = scoreOutcomeOutput(outputText, options.template, drafter.response.qualityScore);
134
+ status = gate.passed ? 'completed' : 'quality_failed';
135
+ } catch {
136
+ status = 'failed';
137
+ }
138
+ } else {
139
+ status = 'failed';
140
+ }
141
+ } else {
142
+ status = 'failed';
143
+ }
144
+ }
145
+
146
+ const totalCostCents = links.reduce((sum, item) => sum + item.actualCents, 0);
147
+ if (status === 'completed') await ledger.settle(hold, totalCostCents);
148
+ else await ledger.refund(hold);
149
+
150
+ const receipt = buildReceipt({
151
+ template: options.template,
152
+ context: options.context,
153
+ hold,
154
+ route,
155
+ blockedOrigins,
156
+ status,
157
+ drafter,
158
+ reviewer,
159
+ fallback,
160
+ links,
161
+ gate,
162
+ totalCostCents,
163
+ durationMs: Date.now() - started,
164
+ posture,
165
+ budgetTier,
166
+ });
167
+ await receiptStore.append(receipt);
168
+ const signedReceipt = await options.guard.recordOutcomeReceipt(receiptToMetadata(receipt));
169
+ return { receipt, signed: signedReceipt.signed, outputText, reviewerOutputText };
170
+ }
171
+
172
+ async function runGuardedModel(args: {
173
+ guard: SpendGuard;
174
+ template: OutcomeTemplate;
175
+ context: OutcomeExecutionContext;
176
+ route: ModelRouteRecommendation;
177
+ role: 'drafter' | 'reviewer' | 'fallback';
178
+ model: string;
179
+ inputs: Record<string, unknown>;
180
+ runner: OutcomeModelRunner;
181
+ priorOutputText?: string;
182
+ }): Promise<ModelRunResult> {
183
+ const request = buildModelRequest(args);
184
+ const inputTokens = args.guard.estimateTokens(request.systemPrompt + '\n' + request.userPrompt);
185
+ const outputTokens = estimatedOutputTokens(args.template);
186
+ const { decision, signed } = await args.guard.decide({
187
+ provider: inferProvider(args.model),
188
+ model: args.model,
189
+ inputTokens,
190
+ outputTokens,
191
+ scope: args.context.scope,
192
+ capabilityClaim: args.template.capability,
193
+ label: args.template.slug + ':' + args.role,
194
+ });
195
+ if (decision.action === 'block') throw new Error('Outcome model call blocked by policy');
196
+
197
+ const started = Date.now();
198
+ let response: OutcomeModelResponse;
199
+ try {
200
+ response = await args.runner.run({ ...request, model: decision.modelResolved });
201
+ } catch (err) {
202
+ await args.guard.settleStreamUsage(decision.decisionId, 0, 0, { partial: true, reason: errorReason(err) });
203
+ throw err;
204
+ }
205
+ const actualInputTokens = validToken(response.inputTokens) ? response.inputTokens : inputTokens;
206
+ const actualOutputTokens = validToken(response.outputTokens) ? response.outputTokens : args.guard.estimateTokens(response.text);
207
+ const computed = computeCallCents(decision.modelResolved, actualInputTokens, actualOutputTokens);
208
+ const actualCents = validCents(response.costCents) ? response.costCents : computed ?? decision.projectedCents;
209
+ await args.guard.settleStreamUsage(decision.decisionId, actualInputTokens, actualOutputTokens, { partial: false });
210
+ return { response, decision, signed, actualInputTokens, actualOutputTokens, actualCents, durationMs: Date.now() - started };
211
+ }
212
+
213
+ function buildModelRequest(args: {
214
+ template: OutcomeTemplate;
215
+ context: OutcomeExecutionContext;
216
+ route: ModelRouteRecommendation;
217
+ role: 'drafter' | 'reviewer' | 'fallback';
218
+ model: string;
219
+ inputs: Record<string, unknown>;
220
+ priorOutputText?: string;
221
+ }): OutcomeModelRequest {
222
+ const contextLines = (args.template.contextInjection ?? []).map((item) => {
223
+ const value = args.context.context?.[item.field] ?? item.example ?? '';
224
+ return item.label + ': ' + value;
225
+ });
226
+ const inputLines = Object.entries(args.inputs).map(([key, value]) => key + ': ' + JSON.stringify(value));
227
+ const userPrompt = [
228
+ 'Outcome: ' + args.template.slug,
229
+ 'Role: ' + args.role,
230
+ ...contextLines,
231
+ ...inputLines,
232
+ args.priorOutputText ? 'Prior drafter output:\n' + args.priorOutputText : '',
233
+ 'Return format: ' + args.template.outputFormat,
234
+ ].filter(Boolean).join('\n');
235
+ return {
236
+ role: args.role,
237
+ model: args.model,
238
+ vertical: String(args.template.vertical),
239
+ outcome: args.template.slug,
240
+ promptVersion: args.template.promptVersion,
241
+ systemPrompt: args.template.systemPrompt + (args.template.disclaimer ? '\n' + args.template.disclaimer : ''),
242
+ userPrompt,
243
+ outputFormat: args.template.outputFormat,
244
+ metadata: {
245
+ vertical: String(args.template.vertical),
246
+ outcome: args.template.slug,
247
+ promptVersion: args.template.promptVersion,
248
+ need: args.route.need,
249
+ role: args.role,
250
+ },
251
+ };
252
+ }
253
+
254
+ function buildReceipt(args: {
255
+ template: OutcomeTemplate;
256
+ context: OutcomeExecutionContext;
257
+ hold: OutcomeSpendHold;
258
+ route: ModelRouteRecommendation;
259
+ blockedOrigins: string[];
260
+ status: OutcomeRuntimeReceipt['status'];
261
+ drafter: ModelRunResult | null;
262
+ reviewer: ModelRunResult | null;
263
+ fallback: OutcomeRuntimeReceipt['fallback'];
264
+ links: OutcomeDecisionLink[];
265
+ gate: OutcomeQualityGateResult;
266
+ totalCostCents: number;
267
+ durationMs: number;
268
+ posture: string;
269
+ budgetTier: string;
270
+ }): OutcomeRuntimeReceipt {
271
+ return {
272
+ receiptId: 'ag_outcome_' + randomUUID(),
273
+ timestamp: new Date().toISOString(),
274
+ vertical: String(args.template.vertical),
275
+ outcome: args.template.slug,
276
+ promptVersion: args.template.promptVersion,
277
+ customerId: args.context.customerId,
278
+ scopeKey: buildScopeKey(args.context.scope),
279
+ status: args.status,
280
+ drafter: modelSummary(args.drafter),
281
+ reviewer: args.reviewer ? { ...modelSummary(args.reviewer), verdict: args.reviewer.response.verdict ?? 'reviewed' } : null,
282
+ fallback: args.fallback,
283
+ triggerFired: args.gate.triggerFired,
284
+ qualityScore: args.gate.score,
285
+ pass: args.status === 'completed',
286
+ totalCostCents: args.totalCostCents,
287
+ durationMs: args.durationMs,
288
+ decisionLinks: args.links,
289
+ metadata: {
290
+ need: args.route.need,
291
+ capability: args.template.capability,
292
+ posture: args.posture,
293
+ budgetTier: args.budgetTier,
294
+ blockedOrigins: args.blockedOrigins,
295
+ provenance: provenanceSummary(args.drafter, args.reviewer),
296
+ },
297
+ };
298
+ }
299
+
300
+ function modelSummary(run: ModelRunResult | null): OutcomeRuntimeReceipt['drafter'] {
301
+ if (!run) return { model: 'none', costCents: 0, inputTokens: 0, outputTokens: 0 };
302
+ return {
303
+ model: run.decision.modelResolved,
304
+ decisionId: run.decision.decisionId,
305
+ costCents: run.actualCents,
306
+ inputTokens: run.actualInputTokens,
307
+ outputTokens: run.actualOutputTokens,
308
+ };
309
+ }
310
+
311
+ function provenanceSummary(drafter: ModelRunResult | null, reviewer: ModelRunResult | null): Record<string, string | number | boolean> {
312
+ const out: Record<string, string | number | boolean> = {};
313
+ if (drafter?.response.durationMs) out.drafterDurationMs = drafter.response.durationMs;
314
+ if (reviewer?.response.durationMs) out.reviewerDurationMs = reviewer.response.durationMs;
315
+ return out;
316
+ }
317
+
318
+ function receiptToMetadata(receipt: OutcomeRuntimeReceipt): Record<string, unknown> {
319
+ return JSON.parse(JSON.stringify(receipt)) as Record<string, unknown>;
320
+ }
321
+
322
+ function linkFor(role: 'drafter' | 'reviewer' | 'fallback', run: ModelRunResult): OutcomeDecisionLink {
323
+ return {
324
+ role,
325
+ decisionId: run.decision.decisionId,
326
+ entryHash: run.signed?.entryHash,
327
+ modelRequested: run.decision.modelRequested,
328
+ modelResolved: run.decision.modelResolved,
329
+ projectedCents: run.decision.projectedCents,
330
+ actualCents: run.actualCents,
331
+ inputTokens: run.actualInputTokens,
332
+ outputTokens: run.actualOutputTokens,
333
+ };
334
+ }
335
+
336
+ function antiCorrelatedReviewer(route: ModelRouteRecommendation, drafterModel: string): string {
337
+ if (familyOf(route.reviewer) !== familyOf(drafterModel)) return route.reviewer;
338
+ return antiCorrelatedFallback(route, drafterModel) ?? route.reviewer;
339
+ }
340
+
341
+ function antiCorrelatedFallback(route: ModelRouteRecommendation, failedModel: string): string | null {
342
+ const failedFamily = familyOf(failedModel);
343
+ return route.allowedModels.find((model) => model !== failedModel && familyOf(model) !== failedFamily && computeCallCents(model, 1, 1) !== null) ?? null;
344
+ }
345
+
346
+ function familyOf(model: string): string {
347
+ const id = model.toLowerCase();
348
+ if (id.includes('opus')) return 'opus';
349
+ if (id.includes('sonnet')) return 'sonnet';
350
+ if (id.includes('haiku')) return 'haiku';
351
+ if (id.includes('gpt-oss')) return 'gpt-oss';
352
+ if (id.includes('gpt-5')) return 'gpt-5';
353
+ if (id.includes('gpt-4')) return 'gpt-4';
354
+ if (id.includes('llama')) return 'llama';
355
+ if (id.includes('mistral')) return 'mistral';
356
+ if (id.includes('nova')) return 'nova';
357
+ return id.split('/')[0] || id.split('.')[0] || 'unknown';
358
+ }
359
+
360
+ function estimatedOutputTokens(template: OutcomeTemplate): number {
361
+ return Math.max(128, Math.min(4096, Math.ceil(template.spendCaps.maxCostCents * 64)));
362
+ }
363
+
364
+ function validateTemplate(template: OutcomeTemplate): void {
365
+ if (!template.slug || !template.vertical) throw new Error('Outcome template must include slug and vertical');
366
+ if (!Number.isSafeInteger(template.spendCaps.maxCostCents) || template.spendCaps.maxCostCents < 0) {
367
+ throw new Error('Outcome spend cap must be a non-negative safe integer');
368
+ }
369
+ }
370
+
371
+ function validateInputs(template: OutcomeTemplate, inputs: Record<string, unknown>): void {
372
+ for (const input of template.inputs) {
373
+ if (input.required && !(input.name in inputs)) throw new Error('Missing required outcome input: ' + input.name);
374
+ }
375
+ }
376
+
377
+ function validToken(value: unknown): value is number {
378
+ return Number.isSafeInteger(value) && (value as number) >= 0;
379
+ }
380
+
381
+ function validCents(value: unknown): value is number {
382
+ return Number.isSafeInteger(value) && (value as number) >= 0;
383
+ }
384
+
385
+ function errorReason(err: unknown): string {
386
+ if (err instanceof Error && err.message) return err.message.slice(0, 120);
387
+ return 'model_error';
388
+ }