/** * `step-extractor` — the first stage of the capture pipeline. * * Converts an `EpisodeSnapshot` (from `core/session`) into a list of * `StepCandidate`s. * * V7 §0.1 granularity: one step ≈ one agent decision point: * - A tool call (model thinking → tool input → tool output) is ONE step. * - The final text response to the user is a SEPARATE step. * - A pure assistant reply with no tool calls is ONE step (unchanged). * * For a turn where the agent called 5 tools then responded, the * extractor produces 6 sub-steps (5 tool + 1 response). Each tool * sub-step carries: * - `userText` = the original user message (shared context / state) * - `agentText` = "" (the action is the tool call itself) * - `toolCalls` = [single ToolCallDTO with input + output] * - `agentThinking` = model thinking for the final response, when * the host provides a turn-level reasoning blob. Tool-call reasoning * lives on `toolCalls[].thinkingBefore`. * - `meta.turnId` = the user turn's `ts`. Stable identifier shared * by every sub-step that came from the same user message — the * viewer uses it to collapse the row of sub-steps back into a * single "one round = one memory" card while the algorithm pipe- * line keeps operating on the step-level traces. * * This matches the algorithm spec `f(1)_{k,t} = (s, a, o, ρ, r)` where * each tool invocation is an independent action `a` with its own * observation `o`, reflection `ρ`, and value `r`. * * The extractor is purely in-memory — no DB, no LLM. */ import type { ToolCallDTO } from "../../agent-contract/dto.js"; import { rootLogger } from "../logger/index.js"; import type { EpisodeSnapshot, EpisodeTurn } from "../session/types.js"; import type { EpochMs } from "../types.js"; import type { StepCandidate } from "./types.js"; export function extractSteps(episode: EpisodeSnapshot): StepCandidate[] { const log = rootLogger.child({ channel: "core.capture.extractor" }); const out: StepCandidate[] = []; const turns = episode.turns; if (turns.length === 0) return []; // Strategy: split on user-role turns (same as before). const segments: EpisodeTurn[][] = []; let current: EpisodeTurn[] = []; for (const turn of turns) { if (turn.role === "user" && current.length > 0) { segments.push(current); current = []; } current.push(turn); } if (current.length > 0) segments.push(current); for (const segTurns of segments) { out.push(...segmentToSteps(segTurns, episode)); } if (out.length === 0) { log.debug("extractor.synthetic_step", { episodeId: episode.id }); const firstUser = turns.find((t) => t.role === "user"); if (firstUser) { out.push({ key: `${episode.id}:0`, ts: firstUser.ts, userText: firstUser.content, agentText: "", agentThinking: null, toolCalls: [], rawReflection: null, depth: depthFromMeta(episode.meta), isSubagent: Boolean(episode.meta.isSubagent), meta: { synthetic: true, turnId: firstUser.ts }, }); } } return out; } // ─── Segment → sub-steps ──────────────────────────────────────────────────── /** * Convert one segment (user turn + tool/assistant turns) into sub-steps. * * When tool turns are present, each tool call becomes its own step so * the batch scorer can assign individual reflections and α scores — * matching the algorithm spec's per-decision-point granularity. */ function segmentToSteps( turns: EpisodeTurn[], episode: EpisodeSnapshot, ): StepCandidate[] { // ─── Classify turns ──────────────────────────────────────────── const userTexts: string[] = []; const toolTurns: EpisodeTurn[] = []; let lastAssistant: EpisodeTurn | null = null; const thinkingParts: string[] = []; let rawReflection: string | null = null; let segMeta: Record = {}; // Stable id shared by every sub-step of the same user message. // Defaults to the first user turn's `ts`; falls back to the first // turn's `ts` for assistant-only segments (rare, but the synthetic // step path also relies on this). let turnId: EpochMs | null = null; for (const turn of turns) { switch (turn.role) { case "user": userTexts.push(turn.content); if (turnId === null) turnId = turn.ts; break; case "tool": toolTurns.push(turn); break; case "assistant": { lastAssistant = turn; const m = turn.meta as Record | undefined; if (!rawReflection) { const r = m?.reflection; if (typeof r === "string" && r.trim()) rawReflection = r.trim(); } const th = m?.agentThinking; if (typeof th === "string" && th.trim()) thinkingParts.push(th.trim()); if (m) segMeta = { ...segMeta, ...m }; break; } // system turns are intentionally skipped } } if (!lastAssistant) return []; const userText = joinNonEmpty(userTexts, "\n---\n"); const depth = depthFromMeta({ ...episode.meta, ...segMeta }); const isSubagent = Boolean(segMeta.isSubagent ?? episode.meta.isSubagent); const fullThinking = thinkingParts.join("\n\n").trim() || null; // Fallback if the segment had no user turn (assistant-only segment // produced by some adapters): anchor turnId on the first turn we // ever saw so downstream group_by still has something stable. const segTurnId: EpochMs = (turnId ?? turns[0]!.ts); // ─── No tool calls → single step ────────────────────────────── if (toolTurns.length === 0) { const assistantTexts = turns .filter((t) => t.role === "assistant") .map((t) => t.content); const agentText = joinNonEmpty(assistantTexts, "\n\n"); const metaToolCalls = collectToolCallsFromMeta(turns); return [{ key: `${episode.id}:${lastAssistant.ts}`, ts: lastAssistant.ts, userText, agentText, agentThinking: fullThinking, toolCalls: metaToolCalls, rawReflection, depth, isSubagent, meta: { ...segMeta, turnId: segTurnId }, }]; } // ─── Tool calls present → one sub-step per tool call ────────── const out: StepCandidate[] = []; const assistantText = lastAssistant.content.trim(); const hasResponse = assistantText.length > 0; const total = toolTurns.length + (hasResponse ? 1 : 0); // Guard against duplicate timestamps when tool turns are added // in a tight synchronous loop (Date.now() has ms resolution). const usedTs = new Set(); function uniqueTs(base: EpochMs): EpochMs { let t = base; while (usedTs.has(t)) t = (t + 1) as EpochMs; usedTs.add(t); return t; } for (let i = 0; i < toolTurns.length; i++) { const tt = toolTurns[i]!; const tc = toolCallFromTurn(tt); if (!tc) continue; const ts = uniqueTs(tt.ts); out.push({ key: `${episode.id}:${ts}:tool:${i}`, ts, // Only the first sub-step carries the user query; subsequent // sub-steps leave `userText` empty so the viewer's flattenChat // doesn't render the same user bubble N times. The turn's // provenance (episodeId) still links them together. userText: i === 0 ? userText : "", agentText: "", agentThinking: !hasResponse && i === 0 ? fullThinking : null, toolCalls: [tc], rawReflection: null, depth, isSubagent, meta: { ...segMeta, subStep: true, subStepIdx: i, subStepTotal: total, turnId: segTurnId, }, }); } if (hasResponse) { const ts = uniqueTs(lastAssistant.ts); out.push({ key: `${episode.id}:${ts}:response`, ts, userText: "", agentText: assistantText, agentThinking: fullThinking, toolCalls: [], rawReflection, depth, isSubagent, meta: { ...segMeta, subStep: true, subStepIdx: toolTurns.length, subStepTotal: total, turnId: segTurnId, }, }); } return out; } // ─── Helpers ──────────────────────────────────────────────────────────────── /** * Fallback: collect tool calls from assistant `meta.toolCalls` for * adapters that don't write separate `role: "tool"` turns. */ function collectToolCallsFromMeta(turns: EpisodeTurn[]): ToolCallDTO[] { const tcs: ToolCallDTO[] = []; for (const turn of turns) { if (turn.role !== "assistant") continue; const metaTcs = (turn.meta as Record | undefined)?.toolCalls; if (!Array.isArray(metaTcs)) continue; for (const raw of metaTcs) { const tc = coerceToolCall(raw); if (tc) tcs.push(tc); } } return tcs; } function toolCallFromTurn(turn: EpisodeTurn): ToolCallDTO | null { const meta = (turn.meta ?? {}) as Record; const direct = coerceToolCall({ name: meta.name ?? meta.tool, input: meta.input ?? meta.args, output: turn.content || meta.output || meta.result, errorCode: meta.errorCode, toolCallId: meta.toolCallId, startedAt: meta.startedAt, endedAt: meta.endedAt, thinkingBefore: meta.thinkingBefore, assistantTextBefore: meta.assistantTextBefore, }); const nested = firstToolCall(meta.toolCalls); const fromContent = firstToolCall(parseJson(turn.content)); const fallback = (direct && direct.name !== "unknown_tool" ? direct : null) ?? (nested ? { ...nested, output: nested.output ?? turn.content } : null) ?? direct ?? fromContent; const tc = unwrapUnknownToolCall(fallback, turn.content); if (tc) return tc; return { name: "unknown_tool", input: meta.input ?? meta.args ?? undefined, output: turn.content, errorCode: typeof meta.errorCode === "string" ? meta.errorCode : undefined, toolCallId: typeof meta.toolCallId === "string" ? meta.toolCallId : undefined, startedAt: typeof meta.startedAt === "number" ? meta.startedAt : undefined, endedAt: typeof meta.endedAt === "number" ? meta.endedAt : undefined, thinkingBefore: typeof meta.thinkingBefore === "string" ? meta.thinkingBefore : undefined, assistantTextBefore: typeof meta.assistantTextBefore === "string" ? meta.assistantTextBefore : undefined, }; } /** * Best-effort coercion of an `assistant.meta.toolCalls[i]` blob into a * `ToolCallDTO`. We accept the rich shape the orchestrator writes * (`{ name, input, output, errorCode, startedAt, endedAt }`) plus the * lossy raw shape some adapters surface (`{ tool, args, result }`). */ function coerceToolCall(raw: unknown): ToolCallDTO | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; const name = typeof r.name === "string" ? r.name : typeof r.tool === "string" ? r.tool : null; if (!name) return null; const input = r.input ?? r.args ?? undefined; const output = r.output ?? r.result ?? undefined; const errorCode = typeof r.errorCode === "string" ? r.errorCode : undefined; const toolCallId = typeof r.toolCallId === "string" ? r.toolCallId : undefined; const startedAt = typeof r.startedAt === "number" ? r.startedAt : undefined; const endedAt = typeof r.endedAt === "number" ? r.endedAt : undefined; const thinkingBefore = typeof r.thinkingBefore === "string" ? r.thinkingBefore : undefined; const assistantTextBefore = typeof r.assistantTextBefore === "string" ? r.assistantTextBefore : undefined; return { name, input, output, errorCode, toolCallId, startedAt, endedAt, thinkingBefore, assistantTextBefore }; } function firstToolCall(raw: unknown): ToolCallDTO | null { if (Array.isArray(raw)) { for (const item of raw) { const tc = coerceToolCall(item); if (tc) return tc; } return null; } return coerceToolCall(raw); } function unwrapUnknownToolCall(tc: ToolCallDTO | null, rawContent: string): ToolCallDTO | null { if (!tc || tc.name !== "unknown_tool") return tc; const nested = firstToolCall(tc.output) ?? firstToolCall(parseJson(typeof tc.output === "string" ? tc.output : rawContent)); if (!nested || nested.name === "unknown_tool") return tc; return { ...nested, thinkingBefore: nested.thinkingBefore ?? tc.thinkingBefore, assistantTextBefore: nested.assistantTextBefore ?? tc.assistantTextBefore, }; } function parseJson(raw: string): unknown { const s = raw.trim(); if (!s) return null; try { return JSON.parse(s) as unknown; } catch { return null; } } function depthFromMeta(meta: Record): number { const raw = meta.depth ?? meta.traceDepth ?? meta.subagentDepth; if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw); return 0; } function joinNonEmpty(parts: readonly string[], sep: string): string { return parts.map((s) => s.trim()).filter((s) => s.length > 0).join(sep); }