404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
/**
|
|
* V7 §2.4.6 — Decision Repair orchestrator.
|
|
*
|
|
* Entry points:
|
|
*
|
|
* - `runRepair(input, deps)` — imperative API. Called by the subscriber
|
|
* whenever a failure burst is detected or a user message is classified
|
|
* as `negative` / `preference`.
|
|
*
|
|
* - `attachRepairToPolicies(draft, deps)` — append the draft's
|
|
* preference / anti-pattern onto the `decisionGuidance` field of each
|
|
* referenced policy. Only runs when `config.attachToPolicy === true`.
|
|
*
|
|
* The orchestrator never mutates state on its own unless it decides to
|
|
* persist: every write is a single atomic insert into `decision_repairs`
|
|
* plus, optionally, per-policy `update`s. Failures are always scoped:
|
|
* logged, emitted as `repair.skipped`, and the orchestrator returns a
|
|
* skip result.
|
|
*/
|
|
|
|
import type { Embedder } from "../embedding/types.js";
|
|
import type { LlmClient } from "../llm/types.js";
|
|
import type { Logger } from "../logger/types.js";
|
|
import type { Repos } from "../storage/repos/index.js";
|
|
import { ids } from "../id.js";
|
|
import { now as nowMs } from "../time.js";
|
|
import type {
|
|
DecisionRepairRow,
|
|
EpochMs,
|
|
PolicyId,
|
|
PolicyRow,
|
|
SessionId,
|
|
TraceRow,
|
|
} from "../types.js";
|
|
import { classifyFeedback } from "./classifier.js";
|
|
import { gatherRepairEvidence } from "./evidence.js";
|
|
import { synthesizeDraft } from "./synthesize.js";
|
|
import type {
|
|
ClassifiedFeedback,
|
|
DecisionRepairDraft,
|
|
FeedbackConfig,
|
|
FeedbackEventBus,
|
|
RepairInput,
|
|
RepairResult,
|
|
RepairTrigger,
|
|
} from "./types.js";
|
|
|
|
export interface RepairDeps {
|
|
repos: Repos;
|
|
llm: LlmClient | null;
|
|
embedder: Embedder | null;
|
|
bus: FeedbackEventBus;
|
|
log: Logger;
|
|
config: FeedbackConfig;
|
|
}
|
|
|
|
// ─── Main orchestrator ─────────────────────────────────────────────────────
|
|
|
|
export async function runRepair(
|
|
input: RepairInput,
|
|
deps: RepairDeps,
|
|
): Promise<RepairResult> {
|
|
const startedAt = nowMs() as EpochMs;
|
|
const log = deps.log;
|
|
const { bus, repos, config } = deps;
|
|
|
|
log.info("repair.run.start", {
|
|
trigger: input.trigger,
|
|
contextHash: input.contextHash,
|
|
toolId: input.toolId,
|
|
});
|
|
|
|
// Cooldown guard — same context, quickly reissued triggers are squelched.
|
|
if (isOnCooldown(repos, input.contextHash, config, startedAt)) {
|
|
log.info("repair.cooldown", { contextHash: input.contextHash });
|
|
bus.emit({
|
|
kind: "repair.skipped",
|
|
at: startedAt,
|
|
contextHash: input.contextHash,
|
|
trigger: input.trigger,
|
|
reason: "cooldown",
|
|
});
|
|
return skip(input, startedAt, "cooldown");
|
|
}
|
|
|
|
// Classify the user text if the caller provided one.
|
|
const classified = input.userText
|
|
? classifyFeedback(input.userText)
|
|
: undefined;
|
|
if (classified) {
|
|
bus.emit({
|
|
kind: "feedback.classified",
|
|
at: startedAt,
|
|
shape: classified.shape,
|
|
confidence: classified.confidence,
|
|
});
|
|
}
|
|
|
|
if (!sessionKnown(input)) {
|
|
bus.emit({
|
|
kind: "repair.skipped",
|
|
at: startedAt,
|
|
contextHash: input.contextHash,
|
|
trigger: input.trigger,
|
|
reason: "no-session",
|
|
});
|
|
return skip(input, startedAt, "no-session");
|
|
}
|
|
|
|
// Gather evidence from recent traces in the same session.
|
|
const evidence = gatherRepairEvidence(
|
|
{
|
|
sessionId: input.sessionId as SessionId,
|
|
keyword: input.toolId ?? classified?.prefer ?? classified?.avoid,
|
|
limit: config.evidenceLimit,
|
|
},
|
|
{ repos, config, log: log.child({ channel: "core.feedback.evidence" }) },
|
|
);
|
|
|
|
const valueDiff = computeValueDiff(evidence.highValue, evidence.lowValue);
|
|
if (valueDiff < config.valueDelta && !classified) {
|
|
// Without an explicit user signal, small deltas aren't worth persisting.
|
|
log.info("repair.valueDiff.below_threshold", {
|
|
contextHash: input.contextHash,
|
|
valueDiff,
|
|
threshold: config.valueDelta,
|
|
});
|
|
bus.emit({
|
|
kind: "repair.skipped",
|
|
at: startedAt,
|
|
contextHash: input.contextHash,
|
|
trigger: input.trigger,
|
|
reason: "value-delta-low",
|
|
});
|
|
return skip(input, startedAt, "value-delta-low");
|
|
}
|
|
|
|
// Look up any policies that both high- and low-value evidence point to
|
|
// so we can attach the draft to them.
|
|
const candidatePolicies = collectCandidatePolicies(
|
|
[...evidence.highValue, ...evidence.lowValue],
|
|
repos,
|
|
);
|
|
|
|
bus.emit({
|
|
kind: "repair.triggered",
|
|
at: startedAt,
|
|
contextHash: input.contextHash,
|
|
trigger: input.trigger,
|
|
failureCount: input.failures?.length,
|
|
});
|
|
|
|
const synth = await synthesizeDraft(
|
|
{
|
|
trigger: input.trigger,
|
|
contextHash: input.contextHash,
|
|
highValue: evidence.highValue,
|
|
lowValue: evidence.lowValue,
|
|
classifiedFeedback: classified,
|
|
toolId: input.toolId,
|
|
candidatePolicies,
|
|
},
|
|
{
|
|
llm: deps.llm,
|
|
log: log.child({ channel: "core.feedback.synthesize" }),
|
|
config,
|
|
},
|
|
);
|
|
|
|
if (!synth.ok) {
|
|
log.info("repair.skipped", {
|
|
contextHash: input.contextHash,
|
|
reason: synth.reason,
|
|
highValue: evidence.highValue.length,
|
|
lowValue: evidence.lowValue.length,
|
|
});
|
|
bus.emit({
|
|
kind: "repair.skipped",
|
|
at: startedAt,
|
|
contextHash: input.contextHash,
|
|
trigger: input.trigger,
|
|
reason: synth.reason,
|
|
});
|
|
return skip(input, startedAt, synth.reason);
|
|
}
|
|
|
|
const row = persistRepair(repos, synth.draft, startedAt);
|
|
log.info("repair.persisted", {
|
|
id: row.id,
|
|
contextHash: row.contextHash,
|
|
confidence: synth.draft.confidence,
|
|
severity: synth.draft.severity,
|
|
});
|
|
bus.emit({
|
|
kind: "repair.persisted",
|
|
at: nowMs() as EpochMs,
|
|
contextHash: row.contextHash,
|
|
repairId: row.id,
|
|
confidence: synth.draft.confidence,
|
|
severity: synth.draft.severity,
|
|
});
|
|
|
|
if (config.attachToPolicy && synth.draft.attachToPolicyIds.length > 0) {
|
|
const attached = attachRepairToPolicies(synth.draft, deps);
|
|
if (attached.length > 0) {
|
|
bus.emit({
|
|
kind: "repair.attached",
|
|
at: nowMs() as EpochMs,
|
|
repairId: row.id,
|
|
policyIds: attached,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
trigger: input.trigger,
|
|
contextHash: input.contextHash,
|
|
repairId: row.id,
|
|
draft: synth.draft,
|
|
skipped: false,
|
|
startedAt,
|
|
completedAt: nowMs() as EpochMs,
|
|
};
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function sessionKnown(input: RepairInput): boolean {
|
|
if (!input.sessionId) return false;
|
|
return Boolean(input.sessionId);
|
|
}
|
|
|
|
function computeValueDiff(high: TraceRow[], low: TraceRow[]): number {
|
|
if (high.length === 0 || low.length === 0) {
|
|
// Any single-side signal is already noteworthy — return a neutral
|
|
// value that still allows the user-triggered branch to fire.
|
|
return Infinity;
|
|
}
|
|
const meanHigh = mean(high.map((t) => t.value));
|
|
const meanLow = mean(low.map((t) => t.value));
|
|
return Math.abs(meanHigh - meanLow);
|
|
}
|
|
|
|
function mean(xs: number[]): number {
|
|
if (xs.length === 0) return 0;
|
|
let sum = 0;
|
|
for (const x of xs) sum += x;
|
|
return sum / xs.length;
|
|
}
|
|
|
|
function collectCandidatePolicies(traces: TraceRow[], repos: Repos): PolicyRow[] {
|
|
// Policies whose sourceEpisodeIds intersect the evidence trace episodes
|
|
// are the policies we want to tag. Fetch the small window of active
|
|
// policies and filter.
|
|
const episodeIds = new Set<string>();
|
|
for (const t of traces) episodeIds.add(t.episodeId as string);
|
|
if (episodeIds.size === 0) return [];
|
|
const policies = repos.policies.list({ status: "active", limit: 200 });
|
|
return policies.filter((p) =>
|
|
p.sourceEpisodeIds.some((eid) => episodeIds.has(eid as string)),
|
|
);
|
|
}
|
|
|
|
function isOnCooldown(
|
|
repos: Repos,
|
|
contextHash: string,
|
|
cfg: FeedbackConfig,
|
|
now: EpochMs,
|
|
): boolean {
|
|
if (cfg.cooldownMs <= 0) return false;
|
|
const recent = repos.decisionRepairs.recentForContext(contextHash);
|
|
if (recent.length === 0) return false;
|
|
const last = recent[0]!;
|
|
return now - last.ts < cfg.cooldownMs;
|
|
}
|
|
|
|
function persistRepair(
|
|
repos: Repos,
|
|
draft: DecisionRepairDraft,
|
|
ts: EpochMs,
|
|
): DecisionRepairRow {
|
|
const owner = ownerFromRepairEvidence(repos, draft);
|
|
const row: DecisionRepairRow = {
|
|
id: ids.decisionRepair(),
|
|
...owner,
|
|
ts,
|
|
contextHash: draft.contextHash,
|
|
preference: draft.preference,
|
|
antiPattern: draft.antiPattern,
|
|
highValueTraceIds: [...draft.highValueTraceIds],
|
|
lowValueTraceIds: [...draft.lowValueTraceIds],
|
|
validated: false,
|
|
};
|
|
repos.decisionRepairs.insert(row);
|
|
return row;
|
|
}
|
|
|
|
function ownerFromRepairEvidence(
|
|
repos: Repos,
|
|
draft: DecisionRepairDraft,
|
|
): { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId: string | null } {
|
|
const traceId = draft.highValueTraceIds[0] ?? draft.lowValueTraceIds[0];
|
|
const trace = traceId ? repos.traces.getById(traceId) : null;
|
|
return {
|
|
ownerAgentKind: trace?.ownerAgentKind ?? "unknown",
|
|
ownerProfileId: trace?.ownerProfileId ?? "default",
|
|
ownerWorkspaceId: trace?.ownerWorkspaceId ?? null,
|
|
};
|
|
}
|
|
|
|
function skip(
|
|
input: RepairInput,
|
|
startedAt: EpochMs,
|
|
reason: string,
|
|
): RepairResult {
|
|
return {
|
|
trigger: input.trigger,
|
|
contextHash: input.contextHash,
|
|
repairId: null,
|
|
draft: null,
|
|
skipped: true,
|
|
skippedReason: reason,
|
|
startedAt,
|
|
completedAt: nowMs() as EpochMs,
|
|
};
|
|
}
|
|
|
|
// ─── Policy attachment ────────────────────────────────────────────────────
|
|
|
|
export interface AttachDeps {
|
|
repos: Repos;
|
|
log: Logger;
|
|
}
|
|
|
|
/**
|
|
* Append the repair draft's preference / anti-pattern onto the candidate
|
|
* policies' decision_guidance metadata. Returns the list of policy IDs
|
|
* that were actually updated.
|
|
*/
|
|
export function attachRepairToPolicies(
|
|
draft: DecisionRepairDraft,
|
|
deps: AttachDeps,
|
|
): PolicyId[] {
|
|
const updated: PolicyId[] = [];
|
|
for (const policyId of draft.attachToPolicyIds) {
|
|
const policy = deps.repos.policies.getById(policyId);
|
|
if (!policy) continue;
|
|
const next = mergePolicyGuidance(policy, draft);
|
|
if (!next) continue;
|
|
deps.repos.policies.upsert(next);
|
|
updated.push(policyId);
|
|
deps.log.debug("repair.attached.policy", { policyId });
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Update a policy's structured `decisionGuidance` column with the new
|
|
* preference / anti-pattern lines from a repair draft. Returns `null`
|
|
* when the merge would be a no-op (every line already present), which
|
|
* lets the caller skip the write entirely.
|
|
*
|
|
* Stored in `policies.decision_guidance_json` (migration 001) — no more
|
|
* regex-parsing the boundary text.
|
|
*/
|
|
function mergePolicyGuidance(
|
|
policy: PolicyRow,
|
|
draft: DecisionRepairDraft,
|
|
): PolicyRow | null {
|
|
const current = policy.decisionGuidance;
|
|
const nextPref = dedupeKeep(current.preference.concat(draft.preference));
|
|
const nextAvoid = dedupeKeep(current.antiPattern.concat(draft.antiPattern));
|
|
if (
|
|
arraysEqual(nextPref, current.preference) &&
|
|
arraysEqual(nextAvoid, current.antiPattern)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
...policy,
|
|
decisionGuidance: { preference: nextPref, antiPattern: nextAvoid },
|
|
updatedAt: nowMs() as PolicyRow["updatedAt"],
|
|
};
|
|
}
|
|
|
|
function dedupeKeep(xs: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const x of xs) {
|
|
const key = x.trim();
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function arraysEqual<T>(a: readonly T[], b: readonly T[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
return true;
|
|
}
|
|
|