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.
- 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 +88 -2
- package/adapters/opencode/opencode.json +1 -1
- 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/schemas/task-outcome-receipt.schema.json +296 -0
- package/openapi/openapi.yaml +235 -0
- package/package.json +19 -6
- package/public/index.html +4 -2
- package/public/numbers.html +2 -2
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +261 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/durability/step.js +121 -12
- package/scripts/gates-engine.js +431 -18
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/prompt-eval.js +81 -4
- package/scripts/schedule-manager.js +249 -0
- package/scripts/task-outcomes.js +425 -0
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-registry.js +143 -0
- 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_${
|
|
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:
|
|
170
|
+
textHash: null,
|
|
175
171
|
reasoning: reasoningRaw ? {
|
|
176
172
|
present: true,
|
|
177
173
|
charCount: String(reasoningRaw).length,
|
|
178
|
-
hash:
|
|
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
|
-
|
|
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
|
|
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:
|
|
824
|
-
context:
|
|
825
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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(
|
|
196
|
+
return await fn(context);
|
|
153
197
|
} catch (err) {
|
|
154
198
|
lastErr = err;
|
|
155
199
|
const outcome = handleStepError({
|
|
156
|
-
err,
|
|
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
|
};
|