thumbgate 1.29.1 → 1.30.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/.claude/commands/dashboard.md +11 -1
- package/.claude/commands/thumbgate-dashboard.md +23 -8
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +61 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +164 -7
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +7 -5
- package/commands/dashboard.md +11 -1
- package/commands/thumbgate-dashboard.md +23 -8
- package/config/agent-outcome-monitor-thresholds.json +63 -0
- package/config/evals/agent-outcomes-baseline.json +17 -0
- package/config/evals/agent-outcomes-golden.json +412 -0
- package/config/evals/prompt-eval-baseline.json +23 -0
- package/config/mcp-allowlists.json +26 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/config/schemas/task-outcome-receipt.schema.json +296 -0
- package/openapi/openapi.yaml +235 -0
- package/package.json +55 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +21 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/loop.svg +34 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +6 -3
- package/public/numbers.html +2 -2
- package/public/whitepaper.html +189 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +331 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/cross-encoder-reranker.js +3 -0
- package/scripts/durability/step.js +121 -12
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +244 -182
- package/scripts/gates-engine.js +512 -22
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-retrieval.js +71 -4
- package/scripts/lesson-search.js +26 -3
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/prompt-eval.js +81 -4
- package/scripts/published-cli.js +11 -1
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/schedule-manager.js +249 -0
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/task-outcomes.js +425 -0
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +192 -1
- package/src/api/server.js +355 -89
|
@@ -268,6 +268,13 @@ function withGateRuntime(options, callback) {
|
|
|
268
268
|
'THUMBGATE_SECRET_SCAN_PROVIDER',
|
|
269
269
|
'THUMBGATE_HARNESS',
|
|
270
270
|
'THUMBGATE_HARNESS_CONFIG',
|
|
271
|
+
// Isolated bench must not inherit operator license / posture / daily-cap state.
|
|
272
|
+
// Without strict enforcement, deny expectations become warn-by-default and the
|
|
273
|
+
// published scorecard is unreproducible on a clean free-tier install.
|
|
274
|
+
'THUMBGATE_STRICT_ENFORCEMENT',
|
|
275
|
+
'THUMBGATE_ENFORCE_ENTITLEMENTS',
|
|
276
|
+
'THUMBGATE_PRO_LICENSE_KEY',
|
|
277
|
+
'THUMBGATE_LICENSE_KEY',
|
|
271
278
|
]);
|
|
272
279
|
const runtimeDir = options.useRuntimeState
|
|
273
280
|
? null
|
|
@@ -289,6 +296,12 @@ function withGateRuntime(options, callback) {
|
|
|
289
296
|
process.env.THUMBGATE_ATTRIBUTED_FEEDBACK = path.join(runtimeDir, 'attributed-feedback.jsonl');
|
|
290
297
|
process.env.THUMBGATE_GUARDS_PATH = path.join(runtimeDir, 'pretool-guards.json');
|
|
291
298
|
process.env.THUMBGATE_SECRET_SCAN_PROVIDER = 'heuristic';
|
|
299
|
+
// Pin enforcement posture for golden deny expectations (scorecard reproducibility).
|
|
300
|
+
process.env.THUMBGATE_STRICT_ENFORCEMENT = '1';
|
|
301
|
+
// Do not hard-fail free-tier installs; isolation already avoids daily-cap state.
|
|
302
|
+
delete process.env.THUMBGATE_ENFORCE_ENTITLEMENTS;
|
|
303
|
+
delete process.env.THUMBGATE_PRO_LICENSE_KEY;
|
|
304
|
+
delete process.env.THUMBGATE_LICENSE_KEY;
|
|
292
305
|
fs.mkdirSync(process.env.THUMBGATE_FEEDBACK_DIR, { recursive: true });
|
|
293
306
|
fs.writeFileSync(process.env.THUMBGATE_FEEDBACK_LOG, '');
|
|
294
307
|
fs.writeFileSync(process.env.THUMBGATE_ATTRIBUTED_FEEDBACK, '');
|
|
@@ -2,75 +2,303 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Tool Contract Validator
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* A dependency-free JSON Schema subset for tool arguments and structured
|
|
7
|
+
* outputs. The validator intentionally fails closed for unsupported schema
|
|
8
|
+
* shapes used at an agent boundary instead of silently accepting them.
|
|
6
9
|
*/
|
|
10
|
+
|
|
11
|
+
const FORMAT_CHECKS = Object.freeze({
|
|
12
|
+
'date-time': (value) => !Number.isNaN(Date.parse(value)) && /T/.test(value),
|
|
13
|
+
email: isEmail,
|
|
14
|
+
uri: (value) => {
|
|
15
|
+
try {
|
|
16
|
+
new URL(value);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
7
24
|
function validateToolContract(schema, args) {
|
|
25
|
+
if (!schema) return { valid: true, errors: [] };
|
|
8
26
|
const errors = [];
|
|
9
|
-
|
|
27
|
+
validateValue(schema, args, '', errors);
|
|
28
|
+
return { valid: errors.length === 0, errors };
|
|
29
|
+
}
|
|
10
30
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
31
|
+
function validateStructuredOutput(output, schema) {
|
|
32
|
+
let value = output;
|
|
33
|
+
if (typeof output === 'string') {
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(output);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
errors: [`Structured output must be valid JSON: ${err.message}`],
|
|
40
|
+
value: null,
|
|
41
|
+
};
|
|
15
42
|
}
|
|
43
|
+
}
|
|
44
|
+
const result = validateToolContract(schema, value);
|
|
45
|
+
return { ...result, value };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateConstant(schema, value, path, errors) {
|
|
49
|
+
if (schema.const !== undefined && !deepEqual(value, schema.const)) {
|
|
50
|
+
errors.push(`${label(path)} must equal ${display(schema.const)} (got ${display(value)})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateEnum(schema, value, path, errors) {
|
|
55
|
+
if (!Array.isArray(schema.enum) || schema.enum.some((entry) => deepEqual(entry, value))) return;
|
|
56
|
+
if (path && schema.type === 'string') {
|
|
57
|
+
errors.push(`Parameter '${path}' must be one of [${schema.enum.join(', ')}] (got '${value}')`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
errors.push(`${label(path)} must be one of [${schema.enum.map(display).join(', ')}] (got ${display(value)})`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateValue(schema, value, path, errors) {
|
|
64
|
+
if (schema === true || !schema) return;
|
|
65
|
+
if (schema === false) {
|
|
66
|
+
errors.push(`${label(path)} is disallowed by schema`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
validateConstant(schema, value, path, errors);
|
|
71
|
+
validateEnum(schema, value, path, errors);
|
|
72
|
+
|
|
73
|
+
validateCombinators(schema, value, path, errors);
|
|
74
|
+
|
|
75
|
+
if (value === undefined) return;
|
|
76
|
+
const allowedTypes = normalizeTypes(schema.type);
|
|
77
|
+
if (allowedTypes.length > 0 && !allowedTypes.some((type) => matchesType(type, value))) {
|
|
78
|
+
errors.push(typeError(path, allowedTypes, value));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (value === null) return;
|
|
83
|
+
const actualType = jsonType(value);
|
|
84
|
+
if (actualType === 'object') validateObject(schema, value, path, errors);
|
|
85
|
+
if (actualType === 'array') validateArray(schema, value, path, errors);
|
|
86
|
+
if (actualType === 'string') validateString(schema, value, path, errors);
|
|
87
|
+
if (actualType === 'number' || actualType === 'integer') validateNumber(schema, value, path, errors);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateCombinators(schema, value, path, errors) {
|
|
91
|
+
if (Array.isArray(schema.allOf)) {
|
|
92
|
+
for (const child of schema.allOf) validateValue(child, value, path, errors);
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(schema.anyOf)) {
|
|
95
|
+
const matches = schema.anyOf.filter((child) => validateChild(child, value).valid).length;
|
|
96
|
+
if (matches === 0) errors.push(`${label(path)} must match at least one anyOf schema`);
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(schema.oneOf)) {
|
|
99
|
+
const matches = schema.oneOf.filter((child) => validateChild(child, value).valid).length;
|
|
100
|
+
if (matches !== 1) errors.push(`${label(path)} must match exactly one oneOf schema (matched ${matches})`);
|
|
101
|
+
}
|
|
102
|
+
if (schema.not && validateChild(schema.not, value).valid) {
|
|
103
|
+
errors.push(`${label(path)} matches a forbidden schema`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateChild(schema, value) {
|
|
108
|
+
const errors = [];
|
|
109
|
+
validateValue(schema, value, '', errors);
|
|
110
|
+
return { valid: errors.length === 0, errors };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validatePropertyCount(schema, keys, path, errors) {
|
|
114
|
+
if (Number.isFinite(schema.minProperties) && keys.length < schema.minProperties) {
|
|
115
|
+
errors.push(`${label(path)} must have at least ${schema.minProperties} properties`);
|
|
116
|
+
}
|
|
117
|
+
if (Number.isFinite(schema.maxProperties) && keys.length > schema.maxProperties) {
|
|
118
|
+
errors.push(`${label(path)} must have at most ${schema.maxProperties} properties`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateRequiredProperties(schema, value, path, errors) {
|
|
123
|
+
for (const required of schema.required || []) {
|
|
124
|
+
if (value[required] === undefined || value[required] === null || value[required] === '') {
|
|
125
|
+
const requiredPath = joinPath(path, required);
|
|
126
|
+
errors.push(path
|
|
127
|
+
? `Parameter '${path}': Missing required parameter: '${required}'`
|
|
128
|
+
: `Missing required parameter: '${required}'`);
|
|
129
|
+
if (path && requiredPath === path) break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateKnownProperties(schema, value, path, errors) {
|
|
135
|
+
const properties = schema.properties || {};
|
|
136
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
137
|
+
if (value[key] === undefined) continue;
|
|
138
|
+
validateValue(childSchema, value[key], joinPath(path, key), errors);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateAdditionalProperties(schema, value, keys, path, errors) {
|
|
143
|
+
const properties = schema.properties || {};
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
if (Object.hasOwn(properties, key)) continue;
|
|
146
|
+
if (schema.additionalProperties === false) {
|
|
147
|
+
errors.push(`Unexpected parameter: '${joinPath(path, key)}'`);
|
|
148
|
+
} else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
149
|
+
validateValue(schema.additionalProperties, value[key], joinPath(path, key), errors);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateObject(schema, value, path, errors) {
|
|
155
|
+
const keys = Object.keys(value);
|
|
156
|
+
validatePropertyCount(schema, keys, path, errors);
|
|
157
|
+
validateRequiredProperties(schema, value, path, errors);
|
|
158
|
+
validateKnownProperties(schema, value, path, errors);
|
|
159
|
+
validateAdditionalProperties(schema, value, keys, path, errors);
|
|
160
|
+
}
|
|
16
161
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
162
|
+
function validateArray(schema, value, path, errors) {
|
|
163
|
+
if (Number.isFinite(schema.minItems) && value.length < schema.minItems) {
|
|
164
|
+
errors.push(`${label(path)} must contain at least ${schema.minItems} items`);
|
|
165
|
+
}
|
|
166
|
+
if (Number.isFinite(schema.maxItems) && value.length > schema.maxItems) {
|
|
167
|
+
errors.push(`${label(path)} must contain at most ${schema.maxItems} items`);
|
|
168
|
+
}
|
|
169
|
+
if (schema.uniqueItems === true) {
|
|
170
|
+
const serialized = value.map(stableStringify);
|
|
171
|
+
if (new Set(serialized).size !== serialized.length) {
|
|
172
|
+
errors.push(`${label(path)} must contain unique items`);
|
|
24
173
|
}
|
|
174
|
+
}
|
|
175
|
+
if (schema.items) {
|
|
176
|
+
value.forEach((entry, index) => validateValue(schema.items, entry, `${path}[${index}]`, errors));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
25
179
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
} else if (propSchema.type === 'number') {
|
|
42
|
-
if (valType !== 'number' || isNaN(value)) {
|
|
43
|
-
errors.push(`Parameter '${key}' must be a number (got ${valType})`);
|
|
44
|
-
}
|
|
45
|
-
} else if (propSchema.type === 'boolean') {
|
|
46
|
-
if (valType !== 'boolean') {
|
|
47
|
-
errors.push(`Parameter '${key}' must be a boolean (got ${valType})`);
|
|
48
|
-
}
|
|
49
|
-
} else if (propSchema.type === 'array') {
|
|
50
|
-
if (!Array.isArray(value)) {
|
|
51
|
-
errors.push(`Parameter '${key}' must be an array (got ${valType})`);
|
|
52
|
-
}
|
|
53
|
-
} else if (propSchema.type === 'object') {
|
|
54
|
-
if (valType !== 'object' || value === null || Array.isArray(value)) {
|
|
55
|
-
errors.push(`Parameter '${key}' must be an object (got ${valType})`);
|
|
56
|
-
} else {
|
|
57
|
-
// Recurse for nested objects
|
|
58
|
-
const subRes = validateToolContract(propSchema, value);
|
|
59
|
-
if (!subRes.valid) {
|
|
60
|
-
for (const err of subRes.errors) {
|
|
61
|
-
errors.push(`Parameter '${key}': ${err}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
180
|
+
function validateString(schema, value, path, errors) {
|
|
181
|
+
if (Number.isFinite(schema.minLength) && value.length < schema.minLength) {
|
|
182
|
+
errors.push(`${label(path)} must contain at least ${schema.minLength} characters`);
|
|
183
|
+
}
|
|
184
|
+
if (Number.isFinite(schema.maxLength) && value.length > schema.maxLength) {
|
|
185
|
+
errors.push(`${label(path)} must contain at most ${schema.maxLength} characters`);
|
|
186
|
+
}
|
|
187
|
+
if (schema.pattern) {
|
|
188
|
+
let regex;
|
|
189
|
+
try {
|
|
190
|
+
regex = new RegExp(schema.pattern);
|
|
191
|
+
} catch {
|
|
192
|
+
errors.push(`${label(path)} uses invalid schema pattern '${schema.pattern}'`);
|
|
193
|
+
return;
|
|
67
194
|
}
|
|
195
|
+
if (!regex.test(value)) errors.push(`${label(path)} must match pattern ${schema.pattern}`);
|
|
196
|
+
}
|
|
197
|
+
if (schema.format && FORMAT_CHECKS[schema.format] && !FORMAT_CHECKS[schema.format](value)) {
|
|
198
|
+
errors.push(`${label(path)} must match format ${schema.format}`);
|
|
68
199
|
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function validateNumber(schema, value, path, errors) {
|
|
203
|
+
if (Number.isFinite(schema.minimum) && value < schema.minimum) {
|
|
204
|
+
errors.push(`${label(path)} must be >= ${schema.minimum}`);
|
|
205
|
+
}
|
|
206
|
+
if (Number.isFinite(schema.maximum) && value > schema.maximum) {
|
|
207
|
+
errors.push(`${label(path)} must be <= ${schema.maximum}`);
|
|
208
|
+
}
|
|
209
|
+
if (Number.isFinite(schema.exclusiveMinimum) && value <= schema.exclusiveMinimum) {
|
|
210
|
+
errors.push(`${label(path)} must be > ${schema.exclusiveMinimum}`);
|
|
211
|
+
}
|
|
212
|
+
if (Number.isFinite(schema.exclusiveMaximum) && value >= schema.exclusiveMaximum) {
|
|
213
|
+
errors.push(`${label(path)} must be < ${schema.exclusiveMaximum}`);
|
|
214
|
+
}
|
|
215
|
+
if (Number.isFinite(schema.multipleOf) && !isMultipleOf(value, schema.multipleOf)) {
|
|
216
|
+
errors.push(`${label(path)} must be a multiple of ${schema.multipleOf}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeTypes(type) {
|
|
221
|
+
if (!type) return [];
|
|
222
|
+
return Array.isArray(type) ? type : [type];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function matchesType(type, value) {
|
|
226
|
+
if (type === 'null') return value === null;
|
|
227
|
+
if (type === 'array') return Array.isArray(value);
|
|
228
|
+
if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
229
|
+
if (type === 'integer') return Number.isInteger(value);
|
|
230
|
+
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
231
|
+
return typeof value === type;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isEmail(value) {
|
|
235
|
+
if (typeof value !== 'string' || /\s/.test(value)) return false;
|
|
236
|
+
const atIndex = value.indexOf('@');
|
|
237
|
+
if (atIndex <= 0 || atIndex !== value.lastIndexOf('@')) return false;
|
|
238
|
+
const domain = value.slice(atIndex + 1);
|
|
239
|
+
const dotIndex = domain.lastIndexOf('.');
|
|
240
|
+
return dotIndex > 0 && dotIndex < domain.length - 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function jsonType(value) {
|
|
244
|
+
if (value === null) return 'null';
|
|
245
|
+
if (Array.isArray(value)) return 'array';
|
|
246
|
+
if (Number.isInteger(value)) return 'integer';
|
|
247
|
+
if (typeof value === 'number') return 'number';
|
|
248
|
+
return typeof value;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function typeError(path, types, value) {
|
|
252
|
+
const expected = types.length === 1 ? types[0] : types.join(' or ');
|
|
253
|
+
const actual = actualTypeName(value);
|
|
254
|
+
if (!path) return `Expected ${expected}, got ${actual}`;
|
|
255
|
+
return `Parameter '${path}' must be ${article(expected)} ${expected} (got ${actual})`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function actualTypeName(value) {
|
|
259
|
+
if (value === null) return 'null';
|
|
260
|
+
if (Array.isArray(value)) return 'array';
|
|
261
|
+
return typeof value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function article(value) {
|
|
265
|
+
return /^[aeiou]/i.test(value) ? 'an' : 'a';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function label(path) {
|
|
269
|
+
return path ? `Parameter '${path}'` : 'Value';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function joinPath(parent, child) {
|
|
273
|
+
return parent ? `${parent}.${child}` : child;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function display(value) {
|
|
277
|
+
if (typeof value === 'string') return `'${value}'`;
|
|
278
|
+
return stableStringify(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function stableStringify(value) {
|
|
282
|
+
if (!value || typeof value !== 'object') return JSON.stringify(value);
|
|
283
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
284
|
+
const keys = Object.keys(value).sort((left, right) => left.localeCompare(right));
|
|
285
|
+
const properties = keys.map((key) => [
|
|
286
|
+
JSON.stringify(key),
|
|
287
|
+
stableStringify(value[key]),
|
|
288
|
+
].join(':'));
|
|
289
|
+
return ['{', properties.join(','), '}'].join('');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function deepEqual(a, b) {
|
|
293
|
+
return stableStringify(a) === stableStringify(b);
|
|
294
|
+
}
|
|
69
295
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
296
|
+
function isMultipleOf(value, multiple) {
|
|
297
|
+
const quotient = value / multiple;
|
|
298
|
+
return Math.abs(quotient - Math.round(quotient)) < Number.EPSILON * 10;
|
|
74
299
|
}
|
|
75
300
|
|
|
76
|
-
module.exports = {
|
|
301
|
+
module.exports = {
|
|
302
|
+
validateStructuredOutput,
|
|
303
|
+
validateToolContract,
|
|
304
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
8
|
+
const { readJsonl } = require('./fs-utils');
|
|
9
|
+
|
|
10
|
+
function getKpiLogPath(options = {}) {
|
|
11
|
+
return path.join(
|
|
12
|
+
options.feedbackDir ? path.resolve(options.feedbackDir) : resolveFeedbackDir(),
|
|
13
|
+
'tool-kpi.jsonl',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function recordToolCall({
|
|
18
|
+
toolName,
|
|
19
|
+
serverName,
|
|
20
|
+
latencyMs,
|
|
21
|
+
success,
|
|
22
|
+
agentId,
|
|
23
|
+
metadata,
|
|
24
|
+
feedbackDir,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const logPath = getKpiLogPath({ feedbackDir });
|
|
27
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
28
|
+
const entry = {
|
|
29
|
+
id: `kpi_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
toolName: toolName || 'unknown',
|
|
32
|
+
serverName: serverName || 'default',
|
|
33
|
+
latencyMs: typeof latencyMs === 'number' ? latencyMs : 0,
|
|
34
|
+
success: success !== false,
|
|
35
|
+
agentId: agentId || 'unknown',
|
|
36
|
+
metadata: metadata || {},
|
|
37
|
+
};
|
|
38
|
+
fs.appendFileSync(logPath, `${JSON.stringify(entry)}\n`);
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function percentile(sorted, quantile) {
|
|
43
|
+
if (sorted.length === 0) return 0;
|
|
44
|
+
const index = Math.ceil((quantile / 100) * sorted.length) - 1;
|
|
45
|
+
return sorted[Math.max(0, index)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function computeToolKpis({ periodHours = 24, feedbackDir } = {}) {
|
|
49
|
+
const entries = readJsonl(getKpiLogPath({ feedbackDir }));
|
|
50
|
+
const cutoff = Date.now() - periodHours * 60 * 60 * 1000;
|
|
51
|
+
const recent = entries.filter((entry) => new Date(entry.timestamp).getTime() > cutoff);
|
|
52
|
+
const byTool = {};
|
|
53
|
+
for (const entry of recent) {
|
|
54
|
+
const key = entry.toolName;
|
|
55
|
+
if (!byTool[key]) {
|
|
56
|
+
byTool[key] = {
|
|
57
|
+
toolName: key,
|
|
58
|
+
calls: [],
|
|
59
|
+
successes: 0,
|
|
60
|
+
failures: 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
byTool[key].calls.push(entry.latencyMs);
|
|
64
|
+
if (entry.success) byTool[key].successes += 1;
|
|
65
|
+
else byTool[key].failures += 1;
|
|
66
|
+
}
|
|
67
|
+
const tools = Object.values(byTool)
|
|
68
|
+
.map((tool) => {
|
|
69
|
+
const sorted = tool.calls.slice().sort((left, right) => left - right);
|
|
70
|
+
const total = tool.successes + tool.failures;
|
|
71
|
+
return {
|
|
72
|
+
toolName: tool.toolName,
|
|
73
|
+
requestCount: total,
|
|
74
|
+
successRate: total > 0 ? Math.round((tool.successes / total) * 1000) / 10 : 100,
|
|
75
|
+
p50: Math.round(percentile(sorted, 50)),
|
|
76
|
+
p90: Math.round(percentile(sorted, 90)),
|
|
77
|
+
p95: Math.round(percentile(sorted, 95)),
|
|
78
|
+
successes: tool.successes,
|
|
79
|
+
failures: tool.failures,
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
.sort((left, right) => right.requestCount - left.requestCount);
|
|
83
|
+
|
|
84
|
+
const byServer = {};
|
|
85
|
+
for (const entry of recent) {
|
|
86
|
+
const key = entry.serverName;
|
|
87
|
+
if (!byServer[key]) byServer[key] = { serverName: key, total: 0, successes: 0 };
|
|
88
|
+
byServer[key].total += 1;
|
|
89
|
+
if (entry.success) byServer[key].successes += 1;
|
|
90
|
+
}
|
|
91
|
+
const servers = Object.values(byServer).map((server) => ({
|
|
92
|
+
serverName: server.serverName,
|
|
93
|
+
totalCalls: server.total,
|
|
94
|
+
successRate: server.total > 0
|
|
95
|
+
? Math.round((server.successes / server.total) * 1000) / 10
|
|
96
|
+
: 100,
|
|
97
|
+
}));
|
|
98
|
+
return {
|
|
99
|
+
periodHours,
|
|
100
|
+
totalCalls: recent.length,
|
|
101
|
+
evidenceStatus: recent.length > 0 ? 'measured' : 'insufficient_evidence',
|
|
102
|
+
tools,
|
|
103
|
+
servers,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getAtRiskTools({
|
|
108
|
+
successRateThreshold = 90,
|
|
109
|
+
p95Threshold = 500,
|
|
110
|
+
periodHours = 24,
|
|
111
|
+
feedbackDir,
|
|
112
|
+
} = {}) {
|
|
113
|
+
const { tools } = computeToolKpis({ periodHours, feedbackDir });
|
|
114
|
+
return tools.filter((tool) => tool.requestCount >= 3
|
|
115
|
+
&& (tool.successRate < successRateThreshold || tool.p95 > p95Threshold));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
computeToolKpis,
|
|
120
|
+
getAtRiskTools,
|
|
121
|
+
getKpiLogPath,
|
|
122
|
+
percentile,
|
|
123
|
+
recordToolCall,
|
|
124
|
+
};
|