import { MAX_EVIDENCE_IDS, type WorkbenchContextSnapshot } from "./contracts.js"; /** * Workbench proposal runner seam. * * A runner receives the exact head version of one working document plus the * operator's exact context snapshot and returns one full replacement body. * The trusted workflow (`workflow.ts`) owns every durable effect: it appends * the requested event, runs the runner, records the run row, appends the * proposal event, and later applies a human decision. A runner never touches * Jazz, the filesystem, or the network on its own. * * Coordination point with the separate fx consumer work: an fx-backed runner * would implement this same interface, map the harness result frame's * `finalText` to `proposedText`, map its run id to `proposer.runId`, and * report the harness's own reason string. That adapter is not implemented * here and nothing in this module imports from `src/agents/harness/`; the * request/result shapes below are the contract the two sides share. */ export interface WorkbenchProposalRequest { documentId: string; baseVersionId: string; baseSha256: string; title: string; baseText: string; snapshot: WorkbenchContextSnapshot; maxProposedChars: number; } export interface WorkbenchProposalResult { proposedText: string; reason: string; evidenceEventIds: string[]; } export interface WorkbenchProposalRunner { id: string; revision: string; /** Plain-language label shown next to the request control. */ label: string; /** Whether the runner performs LLM inference. The fixture runner does not. */ inference: "none" | "model"; propose(request: WorkbenchProposalRequest): Promise; } export const FIXTURE_RUNNER_ID = "fixture-deterministic"; export const FIXTURE_RUNNER_REVISION = "1"; const SOURCES_HEADING = "## Sources"; /** * Deterministic fixture runner. It appends (or replaces) a `## Sources` * section listing each selected event and version, plus a one-line * `Summary:` paragraph counting the sources. Identical input yields identical * output; no inference occurs. */ export const fixtureDeterministicRunner: WorkbenchProposalRunner = { id: FIXTURE_RUNNER_ID, revision: FIXTURE_RUNNER_REVISION, label: "Deterministic fixture (no inference)", inference: "none", async propose(request) { const lines: string[] = [SOURCES_HEADING, ""]; for (const event of request.snapshot.selectedEvents) { const excerpt = event.excerpt.replace(/\s+/g, " ").trim().slice(0, 120); lines.push(`- ${event.type} ${event.eventId.slice(0, 16)} — ${excerpt}`); } for (const version of request.snapshot.selectedVersions) { lines.push(`- version ${version.versionId.slice(0, 24)} (${version.sha256.slice(0, 12)})`); } const count = request.snapshot.selectedEvents.length + request.snapshot.selectedVersions.length; lines.push("", `Summary: this document cites ${count} selected source${count === 1 ? "" : "s"}.`); const base = stripSourcesSection(request.baseText).replace(/\s+$/, ""); const proposedText = `${base}${base ? "\n\n" : ""}${lines.join("\n")}\n`; if (proposedText.length > request.maxProposedChars) { throw new Error("Fixture runner output exceeds the proposal character bound"); } return { proposedText, reason: "fixture: append sources section", evidenceEventIds: request.snapshot.selectedEvents.map((event) => event.eventId).slice(0, MAX_EVIDENCE_IDS), }; }, }; function stripSourcesSection(text: string): string { const index = text.indexOf(`\n${SOURCES_HEADING}`); if (text.startsWith(SOURCES_HEADING)) return ""; if (index === -1) return text; return text.slice(0, index); } export type WorkbenchRunnerRegistry = ReadonlyMap; export function createDefaultWorkbenchRunnerRegistry(): WorkbenchRunnerRegistry { return new Map([[fixtureDeterministicRunner.id, fixtureDeterministicRunner]]); }