dsh-decision-layer 0.7.3 → 0.7.5

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.
@@ -0,0 +1,66 @@
1
+ import { redactCredentials } from "./excerpt.js";
2
+ import { isObject } from "./types.js";
3
+ const KINDS = /* @__PURE__ */ new Set(["gate", "check", "narrow", "complete", "checkpoint"]);
4
+ const TRIGGERS = /* @__PURE__ */ new Set(["heartbeat", "unresolved_failure", "verification_after_change"]);
5
+ const INTEGER_FIELDS = [
6
+ "turn",
7
+ "step",
8
+ "dropped",
9
+ "kept",
10
+ "candidates",
11
+ "conditions",
12
+ "satisfied",
13
+ "unsatisfied",
14
+ "insufficient",
15
+ "throughSeq",
16
+ "evidenceCount",
17
+ "inputTokens",
18
+ "outputTokens",
19
+ "requestBytes",
20
+ "backendAttempts"
21
+ ];
22
+ function sanitizeLogRow(value) {
23
+ if (!isObject(value) || typeof value.kind !== "string" || !KINDS.has(value.kind)) return void 0;
24
+ const row = { kind: value.kind };
25
+ for (const [key, limit] of [
26
+ ["id", 160],
27
+ ["tool", 128],
28
+ ["suggestion", 16],
29
+ ["action", 32],
30
+ ["reason", 64],
31
+ ["subject", 120],
32
+ ["task", 512],
33
+ ["command", 512],
34
+ ["path", 512],
35
+ ["mode", 16],
36
+ ["requestId", 160],
37
+ ["callId", 160],
38
+ ["model", 128]
39
+ ]) {
40
+ const text = value[key];
41
+ if (typeof text === "string" && text.trim()) row[key] = redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, limit);
42
+ }
43
+ for (const key of INTEGER_FIELDS) {
44
+ const n = value[key];
45
+ if (Number.isSafeInteger(n) && n >= 0) row[key] = n;
46
+ }
47
+ for (const key of ["at", "latencyMs", "score", "confidence"]) {
48
+ const n = value[key];
49
+ if (typeof n === "number" && Number.isFinite(n) && n >= 0 && (key !== "confidence" || n <= 1)) {
50
+ row[key] = key === "at" ? n : Math.round(n * 100) / 100;
51
+ }
52
+ }
53
+ row.outcome = typeof value.outcome === "string" ? value.outcome.slice(0, 32) : "";
54
+ if (typeof value.applied === "boolean") row.applied = value.applied;
55
+ if (typeof value.steered === "boolean") row.steered = value.steered;
56
+ if (value.lowConfidence === true) row.lowConfidence = true;
57
+ if (typeof value.trigger === "string" && TRIGGERS.has(value.trigger)) row.trigger = value.trigger;
58
+ if (Array.isArray(value.tools)) {
59
+ const names = value.tools.filter((name) => typeof name === "string" && Boolean(name.trim())).slice(0, 64).map((name) => redactCredentials(name.trim()).slice(0, 128));
60
+ if (names.length) row.tools = names;
61
+ }
62
+ return row;
63
+ }
64
+ export {
65
+ sanitizeLogRow
66
+ };
@@ -13,6 +13,7 @@ export type ManagerRouteDeps = {
13
13
  listRows?: (id: string, options: {
14
14
  page: number;
15
15
  pageSize: number;
16
+ lowOnly?: boolean;
16
17
  }) => Promise<unknown>;
17
18
  env?: Record<string, string | undefined>;
18
19
  bindHost?: string;
@@ -111,7 +111,9 @@ function createManagerRoutes({ path, sessions, backend, logStore, analyze, listR
111
111
  }
112
112
  }),
113
113
  // One newest-first page of this session's full persisted decision rows, for
114
- // the analysis tab's paginated log. Rows carry the same panel fields as /log,
114
+ // the analysis tab's paginated log. `lowOnly=1` filters to low self-check
115
+ // scores before paging, so a single low score in a long session is one click
116
+ // away instead of a manual scan. Rows carry the same panel fields as /log,
115
117
  // including the gate's already-redacted and length-bounded command/path, so
116
118
  // this exposes nothing the recent-rows route does not already.
117
119
  route("logrows", {
@@ -121,8 +123,9 @@ function createManagerRoutes({ path, sessions, backend, logStore, analyze, listR
121
123
  const params = new URL(req.url ?? "/", "http://localhost").searchParams;
122
124
  const page = Number.parseInt(params.get("page") ?? "1", 10);
123
125
  const pageSize = Number.parseInt(params.get("pageSize") ?? "100", 10);
126
+ const lowOnly = params.get("lowOnly") === "1";
124
127
  try {
125
- return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize });
128
+ return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize, ...lowOnly ? { lowOnly } : {} });
126
129
  } catch (error) {
127
130
  if (error?.code === "BAD_REQUEST") throw error;
128
131
  throw codedError("log rows failed", "INTERNAL");
@@ -1,3 +1,4 @@
1
+ import { createEvaluationTrace } from "./evaluation-trace.js";
1
2
  import { taskExcerpt } from "./excerpt.js";
2
3
  import { errorReason, isObject, isProbability } from "./types.js";
3
4
  const narrowApplied = (record) => {
@@ -114,14 +115,14 @@ function createNarrowing({
114
115
  } catch {
115
116
  }
116
117
  };
117
- const evaluateSplit = async (state, tools, signal) => {
118
+ const evaluateSplit = async (evaluate2, state, tools, signal) => {
118
119
  const answers = /* @__PURE__ */ Object.create(null);
119
120
  let anyOk = false;
120
121
  for (let i = 0; i < tools.length; i += concurrency) {
121
122
  const group = tools.slice(i, i + concurrency);
122
123
  const settled = await Promise.all(group.map(async (tool) => {
123
124
  try {
124
- const reply = await evaluate(relevanceQuestions(state, [tool]), { signal });
125
+ const reply = await evaluate2(relevanceQuestions(state, [tool]), { signal });
125
126
  const answer = reply?.answers?.[tool.name];
126
127
  return isObject(answer) ? [tool.name, answer] : void 0;
127
128
  } catch {
@@ -161,28 +162,29 @@ function createNarrowing({
161
162
  const effectiveMode = overCap ? "observe" : mode;
162
163
  const judged = overCap ? optional.slice(0, cap) : optional;
163
164
  const judgedNames = judged.map((tool) => tool.name);
165
+ const trace = createEvaluationTrace(evaluate);
164
166
  let result = null;
165
167
  if (batchMode === "split" || adaptiveSplit) {
166
- result = await evaluateSplit(turn?.state, judged, turn?.signal);
168
+ result = await evaluateSplit(trace.evaluate, turn?.state, judged, turn?.signal);
167
169
  if (!result) {
168
- record(turn, "error", { reason: "unreachable", mode: effectiveMode });
170
+ record(turn, "error", { ...trace.detail(), reason: "unreachable", mode: effectiveMode });
169
171
  return { ...skip, keep: names, mode: effectiveMode };
170
172
  }
171
173
  } else {
172
174
  try {
173
- const reply = await evaluate(relevanceQuestions(turn?.state, judged), { signal: turn?.signal });
175
+ const reply = await trace.evaluate(relevanceQuestions(turn?.state, judged), { signal: turn?.signal });
174
176
  result = reply;
175
177
  } catch (error) {
176
178
  const reason = errorReason(error);
177
179
  if (batchMode === "auto" && reason === "http-error") {
178
180
  adaptiveSplit = true;
179
- result = await evaluateSplit(turn?.state, judged, turn?.signal);
181
+ result = await evaluateSplit(trace.evaluate, turn?.state, judged, turn?.signal);
180
182
  if (!result) {
181
- record(turn, "error", { reason, mode: effectiveMode });
183
+ record(turn, "error", { ...trace.detail(), reason, mode: effectiveMode });
182
184
  return { ...skip, keep: names, mode: effectiveMode };
183
185
  }
184
186
  } else {
185
- record(turn, "error", { reason, mode: effectiveMode });
187
+ record(turn, "error", { ...trace.detail(), reason, mode: effectiveMode });
186
188
  return { ...skip, keep: names, mode: effectiveMode };
187
189
  }
188
190
  }
@@ -198,7 +200,7 @@ function createNarrowing({
198
200
  detail.reason = "too-many-candidates";
199
201
  detail.candidates = optional.length;
200
202
  } else if (dropped > 0 && !trustworthy) detail.reason = "low-keep";
201
- record(turn, dropped > 0 ? "applied" : "ok", detail);
203
+ record(turn, dropped > 0 ? "applied" : "ok", { ...detail, ...trace.detail() });
202
204
  return { evaluated: true, applied, keep, drop, mode: effectiveMode, overCap, trustworthy };
203
205
  }
204
206
  };
@@ -1,13 +1,21 @@
1
+ import { createEvaluationTrace } from "./evaluation-trace.js";
1
2
  import { taskExcerpt } from "./excerpt.js";
2
3
  import { createSteerOnce } from "./steer-once.js";
3
4
  import { errorReason, isProbability } from "./types.js";
4
5
  const MAX_OUTPUT = 24 * 1024;
6
+ const ANSWER_TAIL = 8 * 1024;
7
+ const OMITTED = "\n\n\u2026\uFF08\u4E2D\u95F4\u5185\u5BB9\u5DF2\u7701\u7565\uFF09\u2026\n\n";
5
8
  const DEFAULT_RUBRIC = Object.freeze([
6
9
  "\u660E\u663E\u7B54\u975E\u6240\u95EE\uFF0C\u6216\u5B58\u5728\u4E25\u91CD\u9057\u6F0F / \u81EA\u76F8\u77DB\u76FE",
7
10
  "\u57FA\u672C\u7B54\u5230\u95EE\u9898\uFF0C\u4F46\u6709\u53EF\u5BDF\u89C9\u7684\u9057\u6F0F\u6216\u4E0D\u4E25\u8C28",
8
11
  "\u5B8C\u6574\u3001\u51C6\u786E\u3001\u65E0\u81EA\u76F8\u77DB\u76FE\u5730\u56DE\u5E94\u4E86\u8BF7\u6C42"
9
12
  ]);
10
13
  const STEER_TEXT = "\u672C\u56DE\u5408\u81EA\u68C0\u5F97\u5206\u504F\u4F4E\uFF1A\u8BF7\u590D\u6838\u662F\u5426\u7B54\u5230\u4E86\u7528\u6237\u7684\u95EE\u9898\u3001\u6709\u65E0\u9057\u6F0F\u6216\u81EA\u76F8\u77DB\u76FE\uFF0C\u5E76\u5728\u9700\u8981\u65F6\u8865\u5B8C\u3002";
14
+ function answerExcerpt(output) {
15
+ const text = String(output);
16
+ if (text.length <= MAX_OUTPUT) return text;
17
+ return `${text.slice(0, MAX_OUTPUT - ANSWER_TAIL)}${OMITTED}${text.slice(-ANSWER_TAIL)}`;
18
+ }
11
19
  function boundedRubric(rubric) {
12
20
  if (!Array.isArray(rubric)) return DEFAULT_RUBRIC;
13
21
  const levels = rubric.filter((item) => typeof item === "string" && Boolean(item.trim()) && item.length <= 256).slice(0, 10);
@@ -20,7 +28,12 @@ function scoreQuestion(output, rubric = DEFAULT_RUBRIC, context) {
20
28
  // against nothing and cannot detect an off-topic reply.
21
29
  ...context?.request ? { request: context.request } : {},
22
30
  ...context?.background?.length ? { background: context.background } : {},
23
- answer: String(output).slice(0, MAX_OUTPUT)
31
+ answer: answerExcerpt(output),
32
+ // Short follow-ups ("继续", "补上", "按这个标准重新打分") have no standalone
33
+ // meaning; the task they continue is in background. Saying so explicitly
34
+ // stops the model from treating the follow-up as a new, empty request and
35
+ // from borrowing an unrelated earlier task as this turn's goal.
36
+ ...context ? { note: "request \u662F\u672C\u56DE\u5408\u7528\u6237\u8BF7\u6C42\uFF1B\u82E5\u5B83\u53EA\u662F\u300C\u7EE7\u7EED\u300D\u300C\u8865\u4E0A\u300D\u8FD9\u7C7B\u77ED\u8FFD\u95EE\uFF0C\u8BF7\u7ED3\u5408 background \u4E2D\u627F\u63A5\u7684\u4EFB\u52A1\u5224\u65AD\uFF0C\u4E0D\u8981\u628A background \u91CC\u7684\u65E7\u4EFB\u52A1\u5F53\u6210\u65B0\u7684\u8981\u6C42\u3002background \u53EA\u662F\u80CC\u666F\uFF0C\u4E0D\u662F\u672C\u8F6E\u7684\u65B0\u8981\u6C42\u3002answer \u662F\u672C\u56DE\u5408\u6700\u7EC8\u56DE\u590D\u3002" } : {}
24
37
  },
25
38
  questions: { quality: {
26
39
  type: "score",
@@ -78,16 +91,17 @@ function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence =
78
91
  const taskDetail = task ? { task } : {};
79
92
  if (Number.isSafeInteger(turn?.turn) && turn?.turn >= 0) taskDetail.turn = turn?.turn;
80
93
  const { mode, rubric, lowScoreThreshold, minConfidence: confidenceFloor, trendMinConfidence, trendRun, trendSevereRun } = await resolveSettings();
94
+ const trace = createEvaluationTrace(evaluate);
81
95
  let answer;
82
96
  try {
83
- const result = await evaluate(scoreQuestion(output, rubric, turn?.context), { signal: turn?.signal });
97
+ const result = await trace.evaluate(scoreQuestion(output, rubric, turn?.context), { signal: turn?.signal });
84
98
  answer = result?.answers?.quality;
85
99
  } catch (error) {
86
- record(turn, "error", { ...taskDetail, reason: errorReason(error) });
100
+ record(turn, "error", { ...taskDetail, ...trace.detail(), reason: errorReason(error) });
87
101
  return { evaluated: false, lowScore: false, steered: false };
88
102
  }
89
103
  if (!answer || answer.type !== "score" || typeof answer.score !== "number" || !Number.isFinite(answer.score)) {
90
- record(turn, "error", { ...taskDetail, reason: "invalid-response" });
104
+ record(turn, "error", { ...taskDetail, ...trace.detail(), reason: "invalid-response" });
91
105
  return { evaluated: false, lowScore: false, steered: false };
92
106
  }
93
107
  const confidence = typeof answer.confidence === "number" && Number.isFinite(answer.confidence) ? answer.confidence : void 0;
@@ -95,10 +109,13 @@ function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence =
95
109
  const lowScore = answer.score <= lowScoreThreshold;
96
110
  const trend = { confident: lowScore && confidence !== void 0 && confidence >= trendMinConfidence, trendRun, trendSevereRun };
97
111
  if (lowScore && confidence !== void 0 && !trend.confident) detail.lowConfidence = true;
98
- record(turn, lowScore ? "low" : "ok", { score: answer.score, ...detail }, trend);
99
- if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
112
+ if (!lowScore) {
113
+ record(turn, "ok", { score: answer.score, ...detail, ...trace.detail(), steered: false }, trend);
114
+ return { evaluated: true, lowScore: false, steered: false, score: answer.score };
115
+ }
100
116
  const confident = confidence !== void 0 && confidence >= confidenceFloor;
101
117
  const steered = mode === "steer" && confident && typeof steer === "function" && turn?.agent ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT)) : false;
118
+ record(turn, "low", { score: answer.score, ...detail, ...trace.detail(), steered }, trend);
102
119
  return { evaluated: true, lowScore: true, steered, score: answer.score };
103
120
  }
104
121
  };
@@ -5,8 +5,9 @@ export type SessionStateOptions = {
5
5
  onLog?: (entry: SessionSinkRow) => void;
6
6
  loadState?: (id: string) => Promise<unknown> | unknown;
7
7
  persistState?: (id: string, state: SessionState) => unknown;
8
+ onEnabled?: (id: string, enabled: boolean) => void;
8
9
  };
9
- export declare function createSessionState({ maxSessions, maxLogEntries, onLog, loadState, persistState }?: SessionStateOptions): {
10
+ export declare function createSessionState({ maxSessions, maxLogEntries, onLog, loadState, persistState, onEnabled }?: SessionStateOptions): {
10
11
  ensureLoaded(id: unknown): Promise<void>;
11
12
  setEnabled(id: unknown, enabled: unknown): void;
12
13
  snapshot(id: unknown): SessionSnapshot;
@@ -15,5 +16,6 @@ export declare function createSessionState({ maxSessions, maxLogEntries, onLog,
15
16
  recordCheck(id: unknown, outcome: string, trend?: Record<string, unknown>): void;
16
17
  recordNarrow(id: unknown, outcome: string, dropped?: number, applied?: boolean): void;
17
18
  recordComplete(id: unknown, outcome: string, detail?: Record<string, unknown>): void;
19
+ recordCheckpoint(id: unknown, outcome: string): void;
18
20
  log(id: unknown, entry: Record<string, unknown>): void;
19
21
  };
@@ -1,7 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { redactCredentials } from "./excerpt.js";
2
+ import { sanitizeLogRow } from "./log-row.js";
3
3
  import { advanceRun, severityOf } from "./quality-trend.js";
4
- const KINDS = ["gate", "check", "narrow", "complete"];
5
4
  const SEVERITIES = ["normal", "warn", "severe"];
6
5
  function sessionKey(value) {
7
6
  if (typeof value !== "string" || !value.trim() || value.length > 512) throw new Error("Invalid session ID");
@@ -15,15 +14,13 @@ function emptyCounters() {
15
14
  check: { attempts: 0, failures: 0, low: 0, run: 0, severity: "normal" },
16
15
  narrow: { attempts: 0, failures: 0, applied: 0, dropped: 0 },
17
16
  complete: { attempts: 0, failures: 0, unsatisfied: 0, satisfied: 0, insufficient: 0, steered: 0 },
17
+ checkpoint: { attempts: 0, failures: 0, concern: 0, uncertain: 0, skipped: 0 },
18
18
  log: []
19
19
  };
20
20
  }
21
21
  function emptyState() {
22
22
  return { enabled: true, ...emptyCounters() };
23
23
  }
24
- function safeTool(value) {
25
- return typeof value === "string" && value.trim() ? value.trim().slice(0, 128) : void 0;
26
- }
27
24
  const toCount = (value) => Number.isSafeInteger(value) && value >= 0 ? value : 0;
28
25
  const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
29
26
  function coerceState(raw) {
@@ -71,12 +68,12 @@ function coerceState(raw) {
71
68
  state.complete.insufficient = toCount(k.insufficient);
72
69
  state.complete.steered = toCount(k.steered);
73
70
  }
74
- if (Array.isArray(record.log)) {
75
- state.log = record.log.filter((entry) => Boolean(asRecord(entry)) && typeof entry.kind === "string").map((entry) => ({ ...entry }));
76
- }
71
+ const cp = asRecord(record.checkpoint);
72
+ if (cp) for (const key of ["attempts", "failures", "concern", "uncertain", "skipped"]) state.checkpoint[key] = toCount(cp[key]);
73
+ if (Array.isArray(record.log)) state.log = record.log.map(sanitizeLogRow).filter((row) => Boolean(row));
77
74
  return state;
78
75
  }
79
- function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loadState, persistState } = {}) {
76
+ function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loadState, persistState, onEnabled } = {}) {
80
77
  if (!Number.isSafeInteger(maxSessions) || maxSessions < 1) throw new Error("Invalid session limit");
81
78
  if (!Number.isSafeInteger(maxLogEntries) || maxLogEntries < 1) throw new Error("Invalid log limit");
82
79
  const sessions = /* @__PURE__ */ new Map();
@@ -142,6 +139,10 @@ function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loa
142
139
  if (typeof enabled !== "boolean") throw new Error("Invalid enabled value");
143
140
  const state = writable(id);
144
141
  state.enabled = enabled;
142
+ try {
143
+ onEnabled?.(sessionKey(id), enabled);
144
+ } catch {
145
+ }
145
146
  bump(sessionKey(id), state);
146
147
  },
147
148
  snapshot(id) {
@@ -152,6 +153,7 @@ function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loa
152
153
  if (state.check.attempts > 0) value.check = { attempts: state.check.attempts, failures: state.check.failures, low: state.check.low, trend: { run: state.check.run, severity: state.check.severity } };
153
154
  if (state.narrow.attempts > 0) value.narrow = { ...state.narrow };
154
155
  if (state.complete.attempts > 0) value.complete = { ...state.complete };
156
+ if (state.checkpoint.attempts > 0 || state.checkpoint.skipped > 0) value.checkpoint = { ...state.checkpoint };
155
157
  if (state.log.length > 0) value.log = state.log.map((entry) => ({ ...entry }));
156
158
  return value;
157
159
  },
@@ -233,48 +235,30 @@ function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loa
233
235
  }
234
236
  bump(sessionKey(id), state);
235
237
  },
236
- log(id, entry) {
237
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw new Error("Invalid log entry");
238
- const kind = entry.kind;
239
- if (typeof kind !== "string" || !KINDS.includes(kind)) throw new Error("Invalid log entry");
240
- const record = { id: randomUUID(), at: Date.now(), kind, outcome: typeof entry.outcome === "string" ? entry.outcome.slice(0, 32) : "" };
241
- const tool = safeTool(entry.tool);
242
- if (tool) record.tool = tool;
243
- if (typeof entry.suggestion === "string" && entry.suggestion) record.suggestion = entry.suggestion.slice(0, 16);
244
- if (typeof entry.action === "string" && entry.action) record.action = entry.action.slice(0, 16);
245
- if (typeof entry.reason === "string" && entry.reason) record.reason = entry.reason.slice(0, 32);
246
- if (typeof entry.subject === "string" && entry.subject.trim()) record.subject = redactCredentials(entry.subject).replace(/\s+/g, " ").trim().slice(0, 120);
247
- if (typeof entry.command === "string" && entry.command.trim()) record.command = entry.command.replace(/\s+/g, " ").trim().slice(0, 512);
248
- if (typeof entry.path === "string" && entry.path.trim()) record.path = entry.path.replace(/\s+/g, " ").trim().slice(0, 512);
249
- if (typeof entry.task === "string" && entry.task.trim()) record.task = redactCredentials(entry.task).replace(/\s+/g, " ").trim().slice(0, 512);
250
- if (Number.isSafeInteger(entry.turn) && entry.turn >= 0) record.turn = entry.turn;
251
- if (Number.isSafeInteger(entry.step) && entry.step >= 0) record.step = entry.step;
252
- if (typeof entry.mode === "string" && entry.mode) record.mode = entry.mode.slice(0, 16);
253
- if (typeof entry.score === "number" && Number.isFinite(entry.score)) record.score = Math.round(entry.score * 100) / 100;
254
- if (typeof entry.confidence === "number" && Number.isFinite(entry.confidence)) record.confidence = Math.round(entry.confidence * 100) / 100;
255
- if (Number.isSafeInteger(entry.dropped) && entry.dropped >= 0) record.dropped = entry.dropped;
256
- if (Number.isSafeInteger(entry.kept) && entry.kept >= 0) record.kept = entry.kept;
257
- if (typeof entry.applied === "boolean") record.applied = entry.applied;
258
- if (Number.isSafeInteger(entry.candidates) && entry.candidates >= 0) record.candidates = entry.candidates;
259
- if (Number.isSafeInteger(entry.conditions) && entry.conditions >= 0) record.conditions = entry.conditions;
260
- if (Number.isSafeInteger(entry.satisfied) && entry.satisfied >= 0) record.satisfied = entry.satisfied;
261
- if (Number.isSafeInteger(entry.unsatisfied) && entry.unsatisfied >= 0) record.unsatisfied = entry.unsatisfied;
262
- if (Number.isSafeInteger(entry.insufficient) && entry.insufficient >= 0) record.insufficient = entry.insufficient;
263
- if (entry.steered === true) record.steered = true;
264
- if (entry.lowConfidence === true) record.lowConfidence = true;
265
- if (Array.isArray(entry.tools)) {
266
- const names = entry.tools.filter((name) => typeof name === "string" && Boolean(name.trim())).slice(0, 12).map((name) => name.trim().slice(0, 64));
267
- if (names.length > 0) record.tools = names;
238
+ recordCheckpoint(id, outcome) {
239
+ if (!["ok", "concern", "uncertain", "error", "skipped"].includes(outcome)) throw new Error("Invalid checkpoint outcome");
240
+ const state = writable(id);
241
+ const checkpoint = state.checkpoint;
242
+ if (outcome === "skipped") checkpoint.skipped++;
243
+ else {
244
+ checkpoint.attempts++;
245
+ if (outcome === "error") checkpoint.failures++;
246
+ else if (outcome === "concern") checkpoint.concern++;
247
+ else if (outcome === "uncertain") checkpoint.uncertain++;
268
248
  }
249
+ bump(sessionKey(id), state);
250
+ },
251
+ log(id, entry) {
252
+ const sanitized = sanitizeLogRow(entry);
253
+ if (!sanitized) throw new Error("Invalid log entry");
254
+ const fullRow = { ...sanitized, id: randomUUID(), at: Date.now() };
255
+ const record = { ...fullRow };
256
+ if (record.tools) record.tools = record.tools.slice(0, 12).map((name) => name.slice(0, 64));
269
257
  const state = writable(id);
270
258
  state.log.push(record);
271
259
  if (state.log.length > maxLogEntries) state.log.splice(0, state.log.length - maxLogEntries);
272
260
  if (sink) {
273
- const full = { ...record, sessionId: sessionKey(id) };
274
- if (Array.isArray(entry.tools)) {
275
- const allNames = entry.tools.filter((name) => typeof name === "string" && Boolean(name.trim())).map((name) => name.trim());
276
- if (allNames.length > 0) full.tools = allNames;
277
- }
261
+ const full = { ...fullRow, sessionId: sessionKey(id) };
278
262
  try {
279
263
  sink(full);
280
264
  } catch {
@@ -1,3 +1,4 @@
1
+ import { createEvaluationTrace } from "./evaluation-trace.js";
1
2
  import { taskExcerpt } from "./excerpt.js";
2
3
  import { createSteerOnce } from "./steer-once.js";
3
4
  import { errorReason, isObject, isProbability } from "./types.js";
@@ -122,16 +123,17 @@ function createTaskCompletion({ evaluate, sessions, steer, settings, minConfiden
122
123
  return { ...skip };
123
124
  }
124
125
  }
126
+ const trace = createEvaluationTrace(evaluate);
125
127
  let answers;
126
128
  try {
127
- const result = await evaluate(completionQuestions(conditions, evidence, output, turn?.context), { signal: turn?.signal });
129
+ const result = await trace.evaluate(completionQuestions(conditions, evidence, output, turn?.context), { signal: turn?.signal });
128
130
  answers = isObject(result?.answers) ? result.answers : void 0;
129
131
  } catch (error) {
130
- record(turn, "error", { ...taskDetail, reason: errorReason(error) });
132
+ record(turn, "error", { ...taskDetail, ...trace.detail(), reason: errorReason(error) });
131
133
  return { ...skip };
132
134
  }
133
135
  if (!answers) {
134
- record(turn, "error", { ...taskDetail, reason: "invalid-response" });
136
+ record(turn, "error", { ...taskDetail, ...trace.detail(), reason: "invalid-response" });
135
137
  return { ...skip };
136
138
  }
137
139
  let satisfied = 0, unsatisfied = 0, insufficient = 0;
@@ -153,7 +155,7 @@ function createTaskCompletion({ evaluate, sessions, steer, settings, minConfiden
153
155
  const confident = unsatisfied > 0 && unsatisfiedMinConf >= confidenceFloor;
154
156
  const steered = mode === "steer" && confident && typeof steer === "function" && turn?.agent ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT)) : false;
155
157
  if (steered) detail.steered = true;
156
- record(turn, outcome, detail);
158
+ record(turn, outcome, { ...detail, ...trace.detail(), steered });
157
159
  return { evaluated: true, steered, satisfied, unsatisfied, insufficient };
158
160
  }
159
161
  };
@@ -55,13 +55,24 @@ export type ChoiceAnswer = ScoredAnswer & {
55
55
  };
56
56
  export type Answer = NoulAnswer | ScoreAnswer | ChoiceAnswer;
57
57
  export type AnswerMap = Record<string, Answer>;
58
+ export type EvaluationTelemetry = {
59
+ requestId: string;
60
+ latencyMs: number;
61
+ requestBytes?: number;
62
+ model?: string;
63
+ inputTokens?: number;
64
+ outputTokens?: number;
65
+ backendAttempts?: number;
66
+ };
58
67
  export type EvaluateResult = {
59
68
  model: string;
60
69
  answers: AnswerMap;
61
70
  usage?: {
62
- input_tokens: number;
63
- output_tokens: number;
71
+ input_tokens?: number;
72
+ output_tokens?: number;
64
73
  };
74
+ /** Local measurement only; never part of the backend wire request. */
75
+ telemetry?: EvaluationTelemetry;
65
76
  };
66
77
  export type Evaluator = (input: EvaluateInput, options?: {
67
78
  signal?: AbortSignal;
@@ -102,6 +113,19 @@ export type NarrowSettings = {
102
113
  maxCandidates?: number;
103
114
  batchMode?: BatchMode;
104
115
  };
116
+ export type CheckpointSettings = {
117
+ enabled?: boolean;
118
+ heartbeatSteps?: number;
119
+ failureThreshold?: number;
120
+ timeoutMs?: number;
121
+ };
122
+ export type CheckpointTrigger = 'heartbeat' | 'unresolved_failure' | 'verification_after_change';
123
+ export type CheckpointOutcome = 'ok' | 'concern' | 'uncertain' | 'error' | 'skipped';
124
+ export type CheckpointCounters = SessionCounters & {
125
+ concern: number;
126
+ uncertain: number;
127
+ skipped: number;
128
+ };
105
129
  export type CompleteSettings = {
106
130
  mode?: CheckMode;
107
131
  minConditions?: number;
@@ -126,6 +150,7 @@ export type ResolvedConfig = {
126
150
  checkSettings?: CheckSettings;
127
151
  narrowSettings?: NarrowSettings;
128
152
  completeSettings?: CompleteSettings;
153
+ checkpointSettings?: CheckpointSettings;
129
154
  features?: Features;
130
155
  };
131
156
  /** What the config page may read back: the API key is reduced to a flag. */
@@ -139,6 +164,7 @@ export type ConfigView = {
139
164
  checkSettings?: CheckSettings;
140
165
  narrowSettings?: NarrowSettings;
141
166
  completeSettings?: CompleteSettings;
167
+ checkpointSettings?: CheckpointSettings;
142
168
  features?: Features;
143
169
  };
144
170
  /** The quality-trend knobs, shared by the live path and the log backtest. */
@@ -148,7 +174,7 @@ export type TrendConfig = {
148
174
  trendSevereRun?: number;
149
175
  lowScoreThreshold?: number;
150
176
  };
151
- export type LogKind = 'gate' | 'check' | 'narrow' | 'complete';
177
+ export type LogKind = 'gate' | 'check' | 'narrow' | 'complete' | 'checkpoint';
152
178
  /**
153
179
  * One decision-log row. Fields are optional because rows are also read back
154
180
  * from disk: persisted snapshots are untrusted input, and only `kind` is
@@ -183,6 +209,17 @@ export type LogRow = {
183
209
  steered?: boolean;
184
210
  lowConfidence?: boolean;
185
211
  tools?: string[];
212
+ trigger?: CheckpointTrigger;
213
+ throughSeq?: number;
214
+ evidenceCount?: number;
215
+ requestId?: string;
216
+ latencyMs?: number;
217
+ requestBytes?: number;
218
+ model?: string;
219
+ inputTokens?: number;
220
+ outputTokens?: number;
221
+ backendAttempts?: number;
222
+ callId?: string;
186
223
  };
187
224
  /** The research-log sink record: the panel row plus its session tag. */
188
225
  export type SessionSinkRow = LogRow & {
@@ -226,6 +263,7 @@ export type SessionState = {
226
263
  check: CheckCounters;
227
264
  narrow: NarrowCounters;
228
265
  complete: CompleteCounters;
266
+ checkpoint: CheckpointCounters;
229
267
  log: LogRow[];
230
268
  };
231
269
  /** The panel-facing projection; sections without attempts are omitted. */
@@ -246,6 +284,7 @@ export type SessionSnapshot = {
246
284
  };
247
285
  narrow?: NarrowCounters;
248
286
  complete?: CompleteCounters;
287
+ checkpoint?: CheckpointCounters;
249
288
  log?: LogRow[];
250
289
  };
251
290
  /** The counter shape rebuilt from persisted JSONL, without the master switch. */
@@ -262,6 +301,7 @@ export type SessionsFace = {
262
301
  recordCheck?: (id: string, outcome: string, trend?: Record<string, unknown>) => void;
263
302
  recordNarrow?: (id: string, outcome: string, dropped: number, applied: boolean) => void;
264
303
  recordComplete?: (id: string, outcome: string, detail?: Record<string, unknown>) => void;
304
+ recordCheckpoint?: (id: string, outcome: CheckpointOutcome) => void;
265
305
  ensureLoaded?: (id: string) => Promise<unknown>;
266
306
  setEnabled?: (id: string, enabled: boolean) => void;
267
307
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
5
  "keywords": [
6
6
  "dsh",