/** * `reward.ts` — pipeline orchestrator for Phase 7. * * Lifecycle: * 1. Caller invokes `runner.run({episodeId, feedback, trigger})`. * 2. We load the episode + its traces from storage. * 3. Build the task summary (`task-summary.ts`). * 4. Score R_human (`human-scorer.ts` — LLM or heuristic). * 5. Backprop V_t + priority (`backprop.ts`). * 6. Persist: tracesRepo.updateScore(...) and episodesRepo.setRTask(...). * 7. Emit `reward.updated` so downstream (L2 incremental induction, skill * crystallizer, viewer) can react. * * The runner never throws on recoverable failures — LLM errors fall back * to heuristic scoring, persist errors are reported in `warnings` but * don't crash the agent. A total DB failure does throw; the caller * (subscriber) logs and moves on. */ import { ERROR_CODES, MemosError } from "../../agent-contract/errors.js"; import type { LlmClient } from "../llm/index.js"; import { rootLogger } from "../logger/index.js"; import type { EpisodeId, EpochMs, TraceRow } from "../types.js"; import type { makeEpisodesRepo } from "../storage/repos/episodes.js"; import type { makeFeedbackRepo } from "../storage/repos/feedback.js"; import type { makeTracesRepo } from "../storage/repos/traces.js"; import { backprop } from "./backprop.js"; import { scoreHuman } from "./human-scorer.js"; import { buildTaskSummary } from "./task-summary.js"; import type { RewardConfig, RewardEventBus, RewardInput, RewardResult, UserFeedback, } from "./types.js"; type TracesRepo = ReturnType; type EpisodesRepo = ReturnType; type FeedbackRepo = ReturnType; export interface RewardDeps { tracesRepo: TracesRepo; episodesRepo: EpisodesRepo; feedbackRepo: FeedbackRepo; llm: LlmClient | null; bus: RewardEventBus; cfg: RewardConfig; evaluator?: { reflectionProvider?: string; reflectionModel?: string; scorerProvider?: string; scorerModel?: string; }; now?: () => number; /** * Optional accessor for the episode snapshot (turns + meta). If omitted, * we fall back to the episodes repo's `getById`, which returns a header * row without turns — fine for summary building since we also have the * trace list. When the orchestrator (Phase 15) has a fresher snapshot in * memory, it can inject one here. */ getEpisodeSnapshot?: (id: EpisodeId) => import("../session/types.js").EpisodeSnapshot | null; } export interface RewardRunner { run(input: RewardInput): Promise; } export function createRewardRunner(deps: RewardDeps): RewardRunner { const log = rootLogger.child({ channel: "core.reward" }); const now = deps.now ?? Date.now; if (!deps.llm) { log.warn("reward.llm_unavailable", { impact: "R_human will use heuristic fallback (always 0 without explicit feedback); L2/Skill/L3 pipelines will be skipped", fix: "configure a working LLM provider in config.yaml or ensure the host bridge is attached", }); } async function run(input: RewardInput): Promise { const startedAt = now() as EpochMs; const warnings: RewardResult["warnings"] = []; // Step 0: look up episode + traces. const episode = deps.episodesRepo.getById(input.episodeId); if (!episode) { throw new MemosError(ERROR_CODES.EPISODE_NOT_FOUND, "episode not found", { episodeId: input.episodeId, }); } const snapshot = deps.getEpisodeSnapshot?.(input.episodeId) ?? fallbackSnapshotFromRow(episode); const tMetrics = { summary: 0, score: 0, backprop: 0, persist: 0 } as Record; // Step 1: summary. const tSumStart = now(); const traceIds = episode.traceIds ?? []; const traces: TraceRow[] = traceIds.length > 0 ? deps.tracesRepo .getManyByIds(traceIds as TraceRow["id"][]) .sort((a, b) => a.ts - b.ts) : []; // Step 0.5: triviality gate. Mirrors the legacy memos-local-openclaw // `shouldSkipSummary` rule so single-message / trivial-content // episodes don't pollute the Tasks view with fake "completed" // badges and — more importantly — don't leak into L2 induction as // if they were real proof of a pattern. const skipReason = decideSkipReason(snapshot, traces, deps.cfg); if (skipReason) { log.info("reward.skipped", { episodeId: input.episodeId, sessionId: episode.sessionId, reason: skipReason, }); try { const existingMeta = episode.meta ?? {}; const wasFinalized = existingMeta.closeReason === "finalized"; deps.episodesRepo.updateMeta(input.episodeId, { ...(wasFinalized ? {} : { closeReason: "abandoned", abandonReason: skipReason }), reward: { source: "heuristic", reason: skipReason, scoredAt: startedAt, trigger: input.trigger, skipped: true, }, // A trivial/short episode is a terminal, intentional skip rather // than a failed reward run. Clear any recovery marker inherited // from an earlier capture/reward attempt so automatic rescans do // not keep reflecting the same trace forever. rewardDirty: undefined, }); } catch (err) { warnings.push({ stage: "persist.episode.meta", message: "failed to stamp skip reason", detail: errDetail(err), }); } const completedAt = now() as EpochMs; const skipped: RewardResult = { episodeId: input.episodeId, sessionId: episode.sessionId, rHuman: 0, humanScore: { rHuman: 0, axes: { goalAchievement: 0, processQuality: 0, userSatisfaction: 0, }, reason: skipReason, source: "heuristic", model: null, }, feedbackCount: 0, backprop: { updates: [], meanAbsValue: 0, maxPriority: 0, echoParams: { gamma: deps.cfg.gamma, decayHalfLifeDays: deps.cfg.decayHalfLifeDays, now: startedAt, }, }, traceIds: [], timings: { summary: 0, score: 0, backprop: 0, persist: 0, total: completedAt - startedAt }, warnings, startedAt, completedAt, }; // We deliberately do NOT emit `reward.updated` — the L2/L3/skill // subscribers must not see skipped episodes. Emit a dedicated // `reward.scored` with a null source so the api_logs row still // records something. deps.bus.emit({ kind: "reward.scored", episodeId: input.episodeId, sessionId: episode.sessionId, rHuman: 0, source: "heuristic", }); return skipped; } const summary = buildTaskSummary({ episode: snapshot, traces, cfg: { summaryMaxChars: deps.cfg.summaryMaxChars }, evaluator: deps.evaluator, }); tMetrics.summary = now() - tSumStart; deps.bus.emit({ kind: "reward.scheduled", episodeId: input.episodeId, sessionId: episode.sessionId, }); // Step 2: score. const tScoreStart = now(); const mergedFeedback = mergeFeedback( input.feedback, deps.feedbackRepo.getForEpisode(input.episodeId) as unknown as UserFeedback[], ); const humanScore = await scoreHuman( { episodeSummary: summary, feedback: mergedFeedback }, { llm: deps.llm, cfg: { llmScoring: deps.cfg.llmScoring } }, ); tMetrics.score = now() - tScoreStart; deps.bus.emit({ kind: "reward.scored", episodeId: input.episodeId, sessionId: episode.sessionId, rHuman: humanScore.rHuman, source: humanScore.source, }); // Step 3: backprop. const tBackStart = now(); const bp = backprop({ traces, rHuman: humanScore.rHuman, gamma: deps.cfg.gamma, decayHalfLifeDays: deps.cfg.decayHalfLifeDays, now: startedAt, }); tMetrics.backprop = now() - tBackStart; // Step 4: persist. const tPersistStart = now(); try { for (const u of bp.updates) { deps.tracesRepo.updateScore(u.traceId, { value: u.value, alpha: u.alpha, rHuman: humanScore.rHuman, priority: u.priority, }); } } catch (err) { warnings.push({ stage: "persist.traces", message: "failed to update trace scores", detail: errDetail(err), }); } try { deps.episodesRepo.setRTask(input.episodeId, humanScore.rHuman); } catch (err) { warnings.push({ stage: "persist.episode", message: "failed to update episode r_task", detail: errDetail(err), }); } try { deps.episodesRepo.updateMeta(input.episodeId, { reward: { rHuman: humanScore.rHuman, source: humanScore.source, axes: humanScore.axes, reason: humanScore.reason, scoredAt: startedAt, trigger: input.trigger, traceCount: bp.updates.length, traceIds: bp.updates.map((u) => u.traceId), }, rewardDirty: undefined, }); } catch (err) { warnings.push({ stage: "persist.episode.meta", message: "failed to update episode meta", detail: errDetail(err), }); } tMetrics.persist = now() - tPersistStart; const completedAt = now() as EpochMs; const result: RewardResult = { episodeId: input.episodeId, sessionId: episode.sessionId, rHuman: humanScore.rHuman, humanScore, feedbackCount: mergedFeedback.length, backprop: bp, traceIds: bp.updates.map((u) => u.traceId), timings: { summary: tMetrics.summary!, score: tMetrics.score!, backprop: tMetrics.backprop!, persist: tMetrics.persist!, total: completedAt - startedAt, }, warnings, startedAt, completedAt, }; log.info("reward.done", { episodeId: input.episodeId, sessionId: episode.sessionId, rHuman: humanScore.rHuman, source: humanScore.source, feedbackCount: mergedFeedback.length, traces: bp.updates.length, trigger: input.trigger, totalMs: result.timings.total, warnings: warnings.length, }); deps.bus.emit({ kind: "reward.updated", result }); return result; } return { run }; } // ─── helpers ──────────────────────────────────────────────────────────────── /** * Mirror of the legacy `shouldSkipSummary` rule. Returns a * human-readable reason when the episode is too trivial to score, * else null. We deliberately keep the rule small and deterministic — * no LLM call — because we run it on every episode finalize. * * Ported from `memos-local-openclaw/src/ingest/task-processor.ts` * `shouldSkipSummary`. Skip conditions (any one triggers skip): * 1. Exchange count: `min(user_turns, assistant_turns) < min`. * 2. Content length: total user+assistant chars. * 3. No user messages. * 4. Trivial/test user content (hello, test, ok, etc.). * 5. Tool-result dominated with minimal user interaction. * 6. High content repetition. */ const TRIVIAL_PATTERNS = [ /^(test|testing|hello|hi|hey|ok|okay|yes|no|yeah|nope|sure|thanks|thank you|thx|ping|pong|哈哈|好的|嗯|是的|不是|谢谢|你好|测试)\s*[.!?。!?]*$/i, /^(aaa+|bbb+|xxx+|zzz+|123+|asdf+|qwer+|haha+|lol+|hmm+)\s*$/i, /^[\s\p{P}\p{S}]*$/u, ]; /** * Decide whether `text` is dominated by trivial filler ("ok", "thx", * "test"…). The earlier implementation walked lines and treated any * line shorter than 5 chars as trivial — which mis-fires on long * markdown / code outputs whose 70 %+ of "lines" are structural * (`#`, `-`, `}`, blank-after-trim) rather than filler. That made * the reward gate skip every Hermes turn that included a code block * or a bulleted answer, so the entire L2 / Skill chain starved. * * New rule: weight by *characters*, and only count a line as trivial * when it actually matches `TRIVIAL_PATTERNS`. Short structural lines * are simply ignored. The total non-trivial text must be at least * `MIN_NON_TRIVIAL_CHARS` for the input to pass. */ const MIN_NON_TRIVIAL_CHARS = 30; function looksLikeTrivialContent(text: string): boolean { const lines = text .toLowerCase() .split(/\n/) .map((l) => l.trim()) .filter(Boolean); if (lines.length === 0) return true; let trivialChars = 0; let nonTrivialChars = 0; for (const line of lines) { if (TRIVIAL_PATTERNS.some((p) => p.test(line))) { trivialChars += line.length; } else { nonTrivialChars += line.length; } } // Two-stage check: // - If there's enough genuinely non-trivial text, pass regardless // of how many short structural lines there are. // - Otherwise fall back to the old "trivial-dominated" rule, but // measured in characters (not lines) so a long answer with a // few "ok"s sprinkled in doesn't get rejected. if (nonTrivialChars >= MIN_NON_TRIVIAL_CHARS) return false; const total = trivialChars + nonTrivialChars; if (total === 0) return true; return trivialChars / total > 0.7; } function decideSkipReason( snapshot: import("../session/types.js").EpisodeSnapshot, traces: readonly TraceRow[], cfg: Pick, ): string | null { // Prefer the live snapshot's turn list; fall back to traces when the // snapshot came from a SQLite row (no turns materialised). let userTurns = 0; let assistantTurns = 0; let toolTurns = 0; let contentChars = 0; const userContents: string[] = []; const assistantContents: string[] = []; if (snapshot.turns && snapshot.turns.length > 0) { for (const t of snapshot.turns) { const text = t.content ?? ""; const len = text.length; contentChars += len; if (t.role === "user") { userTurns++; userContents.push(text); } else if (t.role === "assistant") { assistantTurns++; assistantContents.push(text); } else if (t.role === "tool") { toolTurns++; } } } else { for (const tr of traces) { const u = (tr.userText ?? "").length; const a = (tr.agentText ?? "").length; if (u > 0) { userTurns++; contentChars += u; userContents.push(tr.userText); } if (a > 0) { assistantTurns++; contentChars += a; assistantContents.push(tr.agentText); } if (tr.toolCalls && tr.toolCalls.length > 0) { toolTurns += tr.toolCalls.length; } } } const totalTurns = userTurns + assistantTurns + toolTurns; // 1. Not enough real conversation turns (need at least N user-assistant exchanges) const exchanges = Math.min(userTurns, assistantTurns); if (exchanges < cfg.minExchangesForCompletion) { return ( `对话轮次不足(${exchanges} 轮),需要至少 ${cfg.minExchangesForCompletion} 轮完整的问答交互才能生成摘要。` ); } // 2. No user messages at all if (userTurns === 0) { return "该任务没有用户消息,仅包含系统或工具自动生成的内容。"; } // 3. Total content too short. CJK packs ~2× the info per char vs // ASCII, so we double the bar for ASCII-only conversations — but // capped at 2× the user-configured min, never an absolute 200 // (that hardcoded floor used to make `minContentCharsForCompletion` // settings below 100 silently ineffective). const allText = (userContents.join("") + assistantContents.join("")) .slice(0, 4_000); const hasCJK = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(allText); const minContentLen = hasCJK ? cfg.minContentCharsForCompletion : cfg.minContentCharsForCompletion * 2; if (contentChars < minContentLen) { return ( `对话内容过短(${contentChars} 字符),信息量不足以生成有意义的摘要。` ); } // 4. Trivial/test user content const allUserText = userContents.join("\n"); if (looksLikeTrivialContent(allUserText)) { return "对话内容为简单问候或测试数据(如 hello、test、ok),无需生成摘要。"; } // 5. Both sides trivial const allAssistantText = assistantContents.join("\n"); if ( looksLikeTrivialContent(allUserText + "\n" + allAssistantText) ) { return "对话内容(用户和助手双方)为简单问候或测试数据,无需生成摘要。"; } // 6. Almost all messages are tool results with minimal user // interaction AND no real assistant explanation. Single-shot // agent runs (`hermes chat -q "do X"` → write_file + terminal + // brief confirmation) are a legitimate work pattern, not // "missing user interaction". Only skip when the assistant // response is itself trivially short, indicating the turn is // almost pure tool noise. const assistantContentChars = assistantContents.reduce( (sum, c) => sum + c.length, 0, ); const toolHeavyRatio = cfg.toolHeavyRatio ?? 0.7; const minAssistantChars = cfg.minAssistantCharsForToolHeavy ?? 80; if ( toolTurns > 0 && totalTurns > 0 && toolTurns >= totalTurns * toolHeavyRatio && userTurns <= 1 && assistantContentChars < minAssistantChars ) { return `该任务主要由工具执行结果组成(${toolTurns}/${totalTurns} 条),缺少足够的用户交互内容。`; } // 7. High repetition — user keeps saying the same thing if (userContents.length >= 3) { const uniqueUserMsgs = new Set( userContents.map((c) => c.trim().toLowerCase()), ); const uniqueRatio = uniqueUserMsgs.size / userContents.length; if (uniqueRatio < 0.4) { return `对话中存在大量重复内容(${uniqueUserMsgs.size} 条独立消息 / ${userContents.length} 条用户消息),无法提取有效信息。`; } } return null; } function mergeFeedback( a: readonly UserFeedback[], b: readonly UserFeedback[], ): UserFeedback[] { const byId = new Map(); for (const f of a) byId.set(f.id, f); for (const f of b) if (!byId.has(f.id)) byId.set(f.id, f); return [...byId.values()].sort((x, y) => x.ts - y.ts); } function fallbackSnapshotFromRow( ep: NonNullable>, ): import("../session/types.js").EpisodeSnapshot { // Minimal snapshot for summary building when we don't have live turns. return { id: ep.id, sessionId: ep.sessionId, startedAt: ep.startedAt as EpochMs, endedAt: (ep.endedAt ?? null) as EpochMs | null, status: ep.status, rTask: ep.rTask ?? null, turnCount: 0, turns: [], traceIds: (ep.traceIds ?? []) as import("../types.js").TraceId[], meta: (ep as unknown as { meta?: Record }).meta ?? {}, intent: undefined as unknown as import("../session/types.js").IntentDecision, }; } function errDetail(err: unknown): Record { if (err instanceof MemosError) return { code: err.code, message: err.message, ...(err.details ?? {}) }; if (err instanceof Error) return { name: err.name, message: err.message }; return { value: String(err) }; }