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
@@ -107,10 +107,6 @@ function redactTraceText(value, maxLength = MAX_TEXT) {
107
107
  return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
108
108
  }
109
109
 
110
- function hashText(value) {
111
- return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16);
112
- }
113
-
114
110
  function extractMessages(record = {}) {
115
111
  const candidates = [
116
112
  record.steps,
@@ -135,7 +131,7 @@ function normalizeAgentTraceRecord(record = {}, options = {}) {
135
131
  const messages = extractMessages(record);
136
132
  const steps = messages.map((message, index) => normalizeStep(message, index)).filter(Boolean);
137
133
  const taskType = options.taskType || record.taskType || inferTaskType(record, steps);
138
- const traceId = record.traceId || record.id || record.uuid || `trace_${Date.now()}_${hashText(JSON.stringify(record)).slice(0, 8)}`;
134
+ const traceId = record.traceId || record.id || record.uuid || `trace_${crypto.randomUUID()}`;
139
135
  const outcome = normalizeOutcome(record);
140
136
 
141
137
  return {
@@ -171,11 +167,11 @@ function normalizeStep(message = {}, index = 0) {
171
167
  role,
172
168
  eventType,
173
169
  text: eventType === 'reasoning' ? '[REDACTED_REASONING_TRACE]' : redacted,
174
- textHash: hashText(rawContent),
170
+ textHash: null,
175
171
  reasoning: reasoningRaw ? {
176
172
  present: true,
177
173
  charCount: String(reasoningRaw).length,
178
- hash: hashText(reasoningRaw),
174
+ hash: null,
179
175
  } : null,
180
176
  toolCalls,
181
177
  error: detectError(redacted, message),
@@ -191,7 +187,10 @@ function extractToolCalls(message = {}, content = '') {
191
187
  const fn = call.function || call;
192
188
  calls.push({
193
189
  name: String(fn.name || call.name || call.tool || 'unknown'),
194
- argumentsHash: hashText(JSON.stringify(fn.arguments || call.arguments || {})),
190
+ // Deliberately do not persist a deterministic argument fingerprint.
191
+ // Low-entropy values (flags, small IDs, booleans) can be recovered by
192
+ // enumerating candidate inputs even when only a truncated hash is stored.
193
+ argumentsHash: null,
195
194
  });
196
195
  }
197
196
 
@@ -584,7 +583,7 @@ function formatTraceAnalyticsReport(report = {}) {
584
583
  lines.push(`- ${candidate.gateId}: ${candidate.recommendation}`);
585
584
  }
586
585
  if (!report.gateCandidates?.length) lines.push('- None: trace shapes are currently healthy.');
587
- lines.push('', 'Privacy: raw hidden reasoning is not stored; only hashes, event labels, and redacted observable text are retained.', '');
586
+ lines.push('', 'Privacy: raw hidden reasoning and deterministic content fingerprints are not stored; only event labels and redacted observable text are retained.', '');
588
587
  return `${lines.join('\n')}\n`;
589
588
  }
590
589
 
@@ -9,6 +9,7 @@ const { runVerificationLoop } = require('./verification-loop');
9
9
  const { createExperiment } = require('./experiment-tracker');
10
10
  const { recommendEvolutionTarget } = require('./workspace-evolver');
11
11
  const { ensureDir } = require('./fs-utils');
12
+ const { recordTaskOutcome } = require('./task-outcomes');
12
13
 
13
14
  const JOB_LOG_FILENAME = 'job-log.jsonl';
14
15
  const JOB_CONTROL_FILENAME = 'job-control.json';
@@ -306,6 +307,107 @@ function appendJobLog(result) {
306
307
  appendJSONL(getJobRuntimePaths(result.jobId).logPath, result);
307
308
  }
308
309
 
310
+ function taskOutcomeStatus(result, verificationPassed) {
311
+ if (verificationPassed) return 'completed';
312
+ if (result.status === 'failed' || result.status === 'cancelled') return 'failed';
313
+ return 'partial';
314
+ }
315
+
316
+ function attachTaskOutcome(result, state, options = {}) {
317
+ const verification = result.phases?.verification;
318
+ const stageHistory = Array.isArray(state.stageHistory) ? state.stageHistory : [];
319
+ const failed = result.status === 'failed' || result.status === 'cancelled';
320
+ const verificationPassed = verification?.accepted === true;
321
+ const verificationPerformed = Boolean(verification);
322
+ const status = taskOutcomeStatus(result, verificationPassed);
323
+ const evidence = [];
324
+ if (verificationPerformed) {
325
+ evidence.push(`verification score ${verification.score}; attempts ${verification.attempts}`);
326
+ }
327
+ if (state.lastError?.message) evidence.push(`execution error: ${state.lastError.message}`);
328
+
329
+ const outcome = recordTaskOutcome({
330
+ taskId: result.jobId,
331
+ taskType: 'async-job',
332
+ goal: state.jobSpec?.context || `Execute managed job ${result.jobId}`,
333
+ expectedOutcome: 'Complete all stages and pass post-run verification',
334
+ status,
335
+ verification: {
336
+ performed: verificationPerformed,
337
+ passed: verificationPassed,
338
+ verifier: verificationPerformed ? 'verification-loop' : undefined,
339
+ method: verificationPerformed ? 'prevention-rule verification' : undefined,
340
+ evidence,
341
+ unsupportedClaims: 0,
342
+ },
343
+ toolCalls: stageHistory.map((stage) => ({
344
+ name: stage.name,
345
+ contractValid: true,
346
+ allowed: true,
347
+ succeeded: true,
348
+ attempts: 1,
349
+ latencyMs: 0,
350
+ costUsd: 0,
351
+ sideEffect: false,
352
+ duplicateSideEffect: false,
353
+ })),
354
+ policy: {
355
+ violations: 0,
356
+ unsafeEscapes: 0,
357
+ falseBlocks: 0,
358
+ },
359
+ failure: failed ? {
360
+ category: state.lastError?.code || result.status,
361
+ recovered: false,
362
+ repeated: false,
363
+ rolledBack: false,
364
+ } : undefined,
365
+ efficiency: {
366
+ latencyMs: result.durationMs,
367
+ costUsd: 0,
368
+ firstAttempt: verification?.attempts === 1,
369
+ },
370
+ traceId: result.jobId,
371
+ idempotencyKey: `${result.jobId}:${result.status}:${state.endedAt || state.updatedAt}`,
372
+ metadata: {
373
+ source: 'async-job-runner',
374
+ completedStages: stageHistory.length,
375
+ },
376
+ }, options);
377
+ result.taskOutcome = outcome.receipt;
378
+ return result;
379
+ }
380
+
381
+ function verificationFeedbackContext(job, verification) {
382
+ if (!verification) {
383
+ return `Job ${job.id} completed without post-run verification`;
384
+ }
385
+ if (verification.accepted) {
386
+ return `Job ${job.id} passed verification after ${verification.attempts} attempt(s)`;
387
+ }
388
+ const violations = verification.finalVerification?.violations || [];
389
+ const patterns = violations.map((violation) => violation.pattern).join('; ');
390
+ return `Job ${job.id} failed verification after ${verification.attempts} attempt(s): ${patterns}`;
391
+ }
392
+
393
+ function verificationFeedbackFields(verification) {
394
+ if (!verification) {
395
+ return {
396
+ whatWentWrong: 'Post-run verification was skipped, so task success is unverified',
397
+ whatToChange: 'Run standard verification before recording a completed task',
398
+ };
399
+ }
400
+ if (verification.accepted) {
401
+ return {
402
+ whatWorked: 'Verification loop accepted output',
403
+ };
404
+ }
405
+ return {
406
+ whatWentWrong: `Failed ${verification.attempts} verification attempts`,
407
+ whatToChange: 'Improve output to avoid known mistake patterns',
408
+ };
409
+ }
410
+
309
411
  function readJobLog(limit) {
310
412
  const entries = readJSONL(getJobRuntimePaths().logPath);
311
413
  return limit ? entries.slice(-limit) : entries;
@@ -794,6 +896,7 @@ function executeJob(job, options = {}) {
794
896
  feedback,
795
897
  improvementExperiment,
796
898
  });
899
+ attachTaskOutcome(result, failedState);
797
900
  appendJobLog(result);
798
901
  return result;
799
902
  }
@@ -820,19 +923,9 @@ function executeJob(job, options = {}) {
820
923
  const feedback = normalizedJob.recordFeedback === false
821
924
  ? null
822
925
  : captureFeedback({
823
- signal: !verification || verification.accepted ? 'up' : 'down',
824
- context: !verification
825
- ? `Job ${normalizedJob.id} completed without post-run verification`
826
- : verification.accepted
827
- ? `Job ${normalizedJob.id} passed verification after ${verification.attempts} attempt(s)`
828
- : `Job ${normalizedJob.id} failed verification after ${verification.attempts} attempt(s): ${(verification.finalVerification.violations || []).map((violation) => violation.pattern).join('; ')}`,
829
- whatWorked: !verification
830
- ? 'Operational job completed successfully'
831
- : verification.accepted
832
- ? 'Verification loop accepted output'
833
- : undefined,
834
- whatWentWrong: verification && !verification.accepted ? `Failed ${verification.attempts} verification attempts` : undefined,
835
- whatToChange: verification && !verification.accepted ? 'Improve output to avoid known mistake patterns' : undefined,
926
+ signal: verification?.accepted ? 'up' : 'down',
927
+ context: verificationFeedbackContext(normalizedJob, verification),
928
+ ...verificationFeedbackFields(verification),
836
929
  tags: !verification
837
930
  ? [...normalizedJob.tags, 'async-job-runner', 'verification-skipped']
838
931
  : [...normalizedJob.tags, 'verification-loop'],
@@ -863,6 +956,7 @@ function executeJob(job, options = {}) {
863
956
  feedback,
864
957
  improvementExperiment,
865
958
  });
959
+ attachTaskOutcome(result, terminalState);
866
960
  appendJobLog(result);
867
961
  return result;
868
962
  }
@@ -107,7 +107,34 @@ function errMessage(err) {
107
107
  return err?.message ?? err;
108
108
  }
109
109
 
110
- function handleStepError({ err, attempt, retries, classify, backoffMs, name, onRetry, onFail, logger }) {
110
+ function retryAfterMs(err) {
111
+ const raw = err?.retryAfterMs
112
+ ?? err?.headers?.['retry-after-ms']
113
+ ?? err?.headers?.['retry-after'];
114
+ if (raw === undefined || raw === null || raw === '') return null;
115
+ const numeric = Number(raw);
116
+ if (Number.isFinite(numeric)) {
117
+ return String(raw).includes('.') || err?.headers?.['retry-after'] !== undefined
118
+ ? Math.max(0, numeric * 1000)
119
+ : Math.max(0, numeric);
120
+ }
121
+ const dateMs = Date.parse(raw);
122
+ return Number.isNaN(dateMs) ? null : Math.max(0, dateMs - Date.now());
123
+ }
124
+
125
+ function handleStepError({
126
+ err,
127
+ attempt,
128
+ retries,
129
+ classify,
130
+ backoffMs,
131
+ name,
132
+ onRetry,
133
+ onFail,
134
+ logger,
135
+ jitterRatio,
136
+ randomFn,
137
+ }) {
111
138
  const verdict = classify(err);
112
139
  const terminal = verdict === 'fail' || attempt >= retries;
113
140
  if (terminal) {
@@ -117,7 +144,11 @@ function handleStepError({ err, attempt, retries, classify, backoffMs, name, onR
117
144
  }
118
145
  return { terminal: true };
119
146
  }
120
- const waitMs = backoffMs[Math.min(attempt, backoffMs.length - 1)];
147
+ const configuredWait = backoffMs[Math.min(attempt, backoffMs.length - 1)];
148
+ const serverWait = retryAfterMs(err);
149
+ const baseWait = serverWait === null ? configuredWait : Math.max(configuredWait, serverWait);
150
+ const jitter = jitterRatio > 0 ? baseWait * jitterRatio * ((randomFn() * 2) - 1) : 0;
151
+ const waitMs = Math.max(0, Math.round(baseWait + jitter));
121
152
  if (typeof onRetry === 'function') onRetry({ name, attempt, err, waitMs, verdict });
122
153
  if (typeof logger === 'function') {
123
154
  logger(`[step:${name}] RETRY attempt=${attempt} waitMs=${waitMs} err=${errMessage(err)}`);
@@ -126,10 +157,9 @@ function handleStepError({ err, attempt, retries, classify, backoffMs, name, onR
126
157
  }
127
158
 
128
159
  async function runStep(name, options, fn) {
129
- if (typeof options === 'function') {
130
- fn = options;
131
- options = {};
132
- }
160
+ const invocation = normalizeStepInvocation(options, fn);
161
+ options = invocation.options;
162
+ fn = invocation.fn;
133
163
  const {
134
164
  retries = 3,
135
165
  backoffMs = DEFAULT_BACKOFF_MS,
@@ -139,33 +169,112 @@ async function runStep(name, options, fn) {
139
169
  onFail,
140
170
  logger,
141
171
  sleepFn = sleep,
172
+ sideEffect = false,
173
+ idempotencyKey: stepIdempotencyKey,
174
+ maxElapsedMs = Infinity,
175
+ jitterRatio = 0,
176
+ randomFn = Math.random,
142
177
  } = options || {};
143
178
 
144
- if (typeof fn !== 'function') {
145
- throw new TypeError(`runStep(${name}): fn must be a function`);
146
- }
179
+ validateStepConfiguration(name, fn, {
180
+ sideEffect,
181
+ stepIdempotencyKey,
182
+ maxElapsedMs,
183
+ });
147
184
 
148
185
  let lastErr;
186
+ const startedAt = Date.now();
149
187
  for (let attempt = 0; attempt <= retries; attempt++) {
150
- if (typeof onAttempt === 'function') onAttempt({ name, attempt });
188
+ const context = {
189
+ name,
190
+ attempt,
191
+ idempotencyKey: stepIdempotencyKey || null,
192
+ elapsedMs: Date.now() - startedAt,
193
+ };
194
+ callIfFunction(onAttempt, context);
151
195
  try {
152
- return await fn({ attempt });
196
+ return await fn(context);
153
197
  } catch (err) {
154
198
  lastErr = err;
155
199
  const outcome = handleStepError({
156
- err, attempt, retries, classify, backoffMs, name, onRetry, onFail, logger,
200
+ err,
201
+ attempt,
202
+ retries,
203
+ classify,
204
+ backoffMs,
205
+ name,
206
+ onRetry,
207
+ onFail,
208
+ logger,
209
+ jitterRatio: Math.max(0, Number(jitterRatio) || 0),
210
+ randomFn,
157
211
  });
158
212
  if (outcome.terminal) throw err;
213
+ enforceRetryBudget({
214
+ err,
215
+ attempt,
216
+ name,
217
+ onFail,
218
+ startedAt,
219
+ waitMs: outcome.waitMs,
220
+ maxElapsedMs,
221
+ });
159
222
  await sleepFn(outcome.waitMs);
160
223
  }
161
224
  }
162
225
  throw lastErr;
163
226
  }
164
227
 
228
+ function normalizeStepInvocation(options, fn) {
229
+ if (typeof options === 'function') return { options: {}, fn: options };
230
+ return { options: options || {}, fn };
231
+ }
232
+
233
+ function validateStepConfiguration(name, fn, options) {
234
+ if (typeof fn !== 'function') {
235
+ throw new TypeError(`runStep(${name}): fn must be a function`);
236
+ }
237
+ if (options.sideEffect && !String(options.stepIdempotencyKey || '').trim()) {
238
+ const error = new Error(`runStep(${name}): side-effecting steps require idempotencyKey`);
239
+ error.code = 'THUMBGATE_IDEMPOTENCY_KEY_REQUIRED';
240
+ error.nonRetryable = true;
241
+ throw error;
242
+ }
243
+ if (!Number.isFinite(Number(options.maxElapsedMs)) && options.maxElapsedMs !== Infinity) {
244
+ throw new TypeError(`runStep(${name}): maxElapsedMs must be finite or Infinity`);
245
+ }
246
+ }
247
+
248
+ function callIfFunction(callback, payload) {
249
+ if (typeof callback === 'function') callback(payload);
250
+ }
251
+
252
+ function enforceRetryBudget({
253
+ err,
254
+ attempt,
255
+ name,
256
+ onFail,
257
+ startedAt,
258
+ waitMs,
259
+ maxElapsedMs,
260
+ }) {
261
+ if (Date.now() - startedAt + waitMs <= maxElapsedMs) return;
262
+ err.code = err.code || 'THUMBGATE_RETRY_BUDGET_EXHAUSTED';
263
+ err.retryBudgetExhausted = true;
264
+ callIfFunction(onFail, {
265
+ name,
266
+ attempt,
267
+ err,
268
+ verdict: 'retry_budget_exhausted',
269
+ });
270
+ throw err;
271
+ }
272
+
165
273
  module.exports = {
166
274
  runStep,
167
275
  idempotencyKey,
168
276
  defaultClassify,
169
277
  TRANSIENT_CODES,
170
278
  DEFAULT_BACKOFF_MS,
279
+ retryAfterMs,
171
280
  };