thumbgate 1.29.1 → 1.29.2

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 (35) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +61 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/forge/forge.yaml +3 -3
  8. package/adapters/mcp/server-stdio.js +88 -2
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/commands/dashboard.md +11 -1
  11. package/commands/thumbgate-dashboard.md +23 -8
  12. package/config/agent-outcome-monitor-thresholds.json +63 -0
  13. package/config/evals/agent-outcomes-baseline.json +17 -0
  14. package/config/evals/agent-outcomes-golden.json +412 -0
  15. package/config/evals/prompt-eval-baseline.json +23 -0
  16. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  17. package/openapi/openapi.yaml +235 -0
  18. package/package.json +19 -6
  19. package/public/index.html +4 -2
  20. package/public/numbers.html +2 -2
  21. package/scripts/agent-outcome-eval.js +130 -0
  22. package/scripts/agent-outcome-monitor.js +261 -0
  23. package/scripts/agent-reasoning-traces.js +8 -9
  24. package/scripts/async-job-runner.js +107 -13
  25. package/scripts/durability/step.js +121 -12
  26. package/scripts/gates-engine.js +431 -18
  27. package/scripts/human-escalation.js +265 -0
  28. package/scripts/hybrid-feedback-context.js +93 -50
  29. package/scripts/judge-reward-function.js +30 -18
  30. package/scripts/prompt-eval.js +81 -4
  31. package/scripts/schedule-manager.js +249 -0
  32. package/scripts/task-outcomes.js +425 -0
  33. package/scripts/tool-contract-validator.js +287 -59
  34. package/scripts/tool-registry.js +143 -0
  35. package/src/api/server.js +127 -5
@@ -2,75 +2,303 @@
2
2
 
3
3
  /**
4
4
  * Tool Contract Validator
5
- * Validates tool arguments against the tool's inputSchema.
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
- if (!schema) return { valid: true, errors };
27
+ validateValue(schema, args, '', errors);
28
+ return { valid: errors.length === 0, errors };
29
+ }
10
30
 
11
- if (schema.type === 'object') {
12
- if (typeof args !== 'object' || args === null || Array.isArray(args)) {
13
- errors.push(`Expected object, got ${args === null ? 'null' : Array.isArray(args) ? 'array' : typeof args}`);
14
- return { valid: false, errors };
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
- // Check required fields
18
- if (Array.isArray(schema.required)) {
19
- for (const req of schema.required) {
20
- if (args[req] === undefined || args[req] === null || args[req] === '') {
21
- errors.push(`Missing required parameter: '${req}'`);
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
- // Check properties
27
- if (schema.properties) {
28
- for (const [key, propSchema] of Object.entries(schema.properties)) {
29
- const value = args[key];
30
- if (value === undefined || value === null) continue; // Optional field not provided
31
-
32
- const valType = typeof value;
33
- if (propSchema.type === 'string') {
34
- if (valType !== 'string') {
35
- errors.push(`Parameter '${key}' must be a string (got ${valType})`);
36
- } else if (Array.isArray(propSchema.enum)) {
37
- if (!propSchema.enum.includes(value)) {
38
- errors.push(`Parameter '${key}' must be one of [${propSchema.enum.join(', ')}] (got '${value}')`);
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
- return {
71
- valid: errors.length === 0,
72
- errors,
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 = { validateToolContract };
301
+ module.exports = {
302
+ validateStructuredOutput,
303
+ validateToolContract,
304
+ };
@@ -60,6 +60,79 @@ const GOAL_CONTRACT_SCHEMA = {
60
60
  },
61
61
  };
62
62
 
63
+ const TASK_OUTCOME_INPUT_SCHEMA = {
64
+ type: 'object',
65
+ additionalProperties: false,
66
+ required: ['taskId', 'goal', 'status', 'verification', 'toolCalls', 'policy', 'efficiency'],
67
+ properties: {
68
+ taskId: { type: 'string', minLength: 1 },
69
+ taskType: { type: 'string' },
70
+ goal: { type: 'string', minLength: 1 },
71
+ expectedOutcome: { type: 'string' },
72
+ status: { type: 'string', enum: ['completed', 'failed', 'partial', 'escalated'] },
73
+ verification: {
74
+ type: 'object',
75
+ additionalProperties: false,
76
+ required: ['performed', 'passed', 'evidence'],
77
+ properties: {
78
+ performed: { type: 'boolean' },
79
+ passed: { type: 'boolean' },
80
+ verifier: { type: 'string' },
81
+ method: { type: 'string' },
82
+ evidence: { type: 'array', items: { type: 'string' } },
83
+ unsupportedClaims: { type: 'integer', minimum: 0 },
84
+ },
85
+ },
86
+ toolCalls: {
87
+ type: 'array',
88
+ items: {
89
+ type: 'object',
90
+ additionalProperties: false,
91
+ required: ['name', 'contractValid', 'allowed', 'succeeded', 'attempts'],
92
+ properties: {
93
+ name: { type: 'string' },
94
+ contractValid: { type: 'boolean' },
95
+ allowed: { type: 'boolean' },
96
+ succeeded: { type: 'boolean' },
97
+ attempts: { type: 'integer', minimum: 1 },
98
+ latencyMs: { type: 'number', minimum: 0 },
99
+ costUsd: { type: 'number', minimum: 0 },
100
+ sideEffect: { type: 'boolean' },
101
+ idempotencyKey: { type: 'string' },
102
+ duplicateSideEffect: { type: 'boolean' },
103
+ },
104
+ },
105
+ },
106
+ policy: {
107
+ type: 'object',
108
+ additionalProperties: false,
109
+ required: ['violations', 'unsafeEscapes', 'falseBlocks'],
110
+ properties: {
111
+ violations: { type: 'integer', minimum: 0 },
112
+ unsafeEscapes: { type: 'integer', minimum: 0 },
113
+ falseBlocks: { type: 'integer', minimum: 0 },
114
+ },
115
+ },
116
+ failure: { type: 'object', additionalProperties: true },
117
+ escalation: { type: 'object', additionalProperties: true },
118
+ efficiency: {
119
+ type: 'object',
120
+ additionalProperties: false,
121
+ required: ['latencyMs', 'costUsd'],
122
+ properties: {
123
+ latencyMs: { type: 'number', minimum: 0 },
124
+ costUsd: { type: 'number', minimum: 0 },
125
+ firstAttempt: { type: 'boolean' },
126
+ },
127
+ },
128
+ businessOutcome: { type: 'object', additionalProperties: true },
129
+ traceId: { type: 'string' },
130
+ idempotencyKey: { type: 'string' },
131
+ versions: { type: 'object', additionalProperties: true },
132
+ metadata: { type: 'object', additionalProperties: true },
133
+ },
134
+ };
135
+
63
136
  const TOOLS = [
64
137
  readOnlyTool({
65
138
  name: 'capture_feedback',
@@ -969,6 +1042,76 @@ const TOOLS = [
969
1042
  },
970
1043
  },
971
1044
  }),
1045
+ destructiveTool({
1046
+ name: 'record_task_outcome',
1047
+ title: 'Record Verified Task Outcome',
1048
+ description: 'Record an idempotent task-level outcome with verification evidence, tool correctness, policy behavior, latency, cost, and business KPI movement. A completed response without evidence is recorded as not working.',
1049
+ inputSchema: TASK_OUTCOME_INPUT_SCHEMA,
1050
+ }),
1051
+ readOnlyTool({
1052
+ name: 'get_task_outcomes',
1053
+ title: 'Get Task Outcomes',
1054
+ description: 'Read a task outcome by taskId or the most recent evidence-backed outcome receipts.',
1055
+ inputSchema: {
1056
+ type: 'object',
1057
+ additionalProperties: false,
1058
+ properties: {
1059
+ taskId: { type: 'string' },
1060
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
1061
+ },
1062
+ },
1063
+ }),
1064
+ readOnlyTool({
1065
+ name: 'get_agent_outcome_metrics',
1066
+ title: 'Get Agent Outcome Metrics',
1067
+ description: 'Compute transparent task, tool, safety, escalation, latency, cost, and business metrics from recorded task outcomes. Empty data returns insufficient_evidence.',
1068
+ inputSchema: {
1069
+ type: 'object',
1070
+ additionalProperties: false,
1071
+ properties: {},
1072
+ },
1073
+ }),
1074
+ destructiveTool({
1075
+ name: 'request_human_escalation',
1076
+ title: 'Request Human Escalation',
1077
+ description: 'Create an idempotent, expiring human-review request with requester identity and evidence. Agents cannot approve their own requests.',
1078
+ inputSchema: {
1079
+ type: 'object',
1080
+ additionalProperties: false,
1081
+ required: ['taskId', 'reason', 'requester', 'evidence'],
1082
+ properties: {
1083
+ taskId: { type: 'string', minLength: 1 },
1084
+ reason: { type: 'string', minLength: 1 },
1085
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
1086
+ requester: {
1087
+ type: 'object',
1088
+ additionalProperties: false,
1089
+ required: ['id', 'kind'],
1090
+ properties: {
1091
+ id: { type: 'string', minLength: 1 },
1092
+ kind: { type: 'string', enum: ['agent', 'service', 'human'] },
1093
+ displayName: { type: 'string' },
1094
+ },
1095
+ },
1096
+ evidence: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
1097
+ ttlMs: { type: 'number', minimum: 1 },
1098
+ idempotencyKey: { type: 'string' },
1099
+ },
1100
+ },
1101
+ }),
1102
+ readOnlyTool({
1103
+ name: 'list_human_escalations',
1104
+ title: 'List Human Escalations',
1105
+ description: 'List auditable human-escalation state. Approval decisions are deliberately absent from the agent tool surface.',
1106
+ inputSchema: {
1107
+ type: 'object',
1108
+ additionalProperties: false,
1109
+ properties: {
1110
+ status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'cancelled', 'expired'] },
1111
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
1112
+ },
1113
+ },
1114
+ }),
972
1115
  readOnlyTool({
973
1116
  name: 'verify_claim',
974
1117
  description: 'Check whether a claim has enough tracked evidence before the agent asserts it.',